Compare commits

..

163 Commits

Author SHA1 Message Date
Joe Julian c19bdd48e2 Simplify desktop remote open flow
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m42s
2026-04-05 21:34:59 -07:00
Joe Julian e5f42924f8 Simplify desktop unlock flow
ci / lint-test (push) Successful in 1m16s
ci / build (push) Successful in 2m39s
2026-04-05 21:24:36 -07:00
Joe Julian 13eb9028c7 Add Arch package for Linux client
ci / lint-test (push) Successful in 1m21s
ci / build (push) Successful in 2m45s
2026-04-05 20:31:17 -07:00
Joe Julian 5fa79bdf08 Move unlock controls ahead of vault summary
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s
2026-04-05 18:35:42 -07:00
Joe Julian 1a11944423 Pin Gio for Android rendering
ci / lint-test (push) Successful in 1m47s
ci / build (push) Successful in 3m34s
2026-04-05 16:55:16 -07:00
Joe Julian b0842566c0 Document Android APK test workflow 2026-04-05 16:51:38 -07:00
Joe Julian eb6624cba5 Simplify recent vault open flow and Android local sync
ci / lint-test (push) Successful in 1m46s
ci / build (push) Successful in 3m44s
2026-04-05 16:37:43 -07:00
Joe Julian 37f1a0ef8f Use Gio clipboard commands on Android
ci / lint-test (push) Successful in 1m45s
ci / build (push) Has been cancelled
2026-04-04 09:41:28 -07:00
Joe Julian da4a8d4622 Trigger CI after Traefik TLS fix 2026-04-03 21:41:20 -07:00
Joe Julian 81df59ee15 Use system CA for CI artifact uploads 2026-04-03 20:37:27 -07:00
Joe Julian 69fac41003 Enable cgo for Linux desktop CI builds 2026-04-03 20:31:34 -07:00
Joe Julian 1ee916b863 Limit CI desktop builds to supported targets 2026-04-03 20:27:13 -07:00
Joe Julian 9c41b4814b Use dedicated Android CI runner 2026-04-03 18:04:53 -07:00
Joe Julian 2f4f016fe7 Match Gitea workflow runner labels 2026-04-03 17:23:38 -07:00
Joe Julian 832aa86a34 Add Gitea CI and release pipeline 2026-04-03 16:38:56 -07:00
Joe Julian a4933f2127 Route Android back through Gio settings flow 2026-04-03 11:21:17 -07:00
Joe Julian 6afdf49f5e Add API policy pickers and contextual search labels 2026-04-03 10:55:17 -07:00
Joe Julian 9269c4b488 Remove redundant recycle bin notice 2026-04-03 10:45:47 -07:00
Joe Julian 78313f130d Fix group tools disclosure icon 2026-04-03 10:43:07 -07:00
Joe Julian e75b3eb418 Harden phone entry click state 2026-04-03 10:38:14 -07:00
Joe Julian ae7483e929 Limit group view to direct entries 2026-04-03 08:51:19 -07:00
Joe Julian db0501fca0 Keep parent breadcrumb on phone 2026-04-03 08:35:09 -07:00
Joe Julian 6629ba2483 Snapshot phone entry list during redraw 2026-04-03 08:28:58 -07:00
Joe Julian 3dc0f13af9 Fix phone header spacer 2026-04-03 07:25:25 -07:00
Joe Julian b3db70db91 Autosave Android settings and refine header 2026-04-03 07:23:31 -07:00
Joe Julian e2addfc288 Right-align phone action row 2026-04-03 07:19:26 -07:00
Joe Julian e0fba15246 Align phone header actions right 2026-04-03 07:18:10 -07:00
Joe Julian a8bb5bf6b9 Tighten phone group and detail layout 2026-04-03 07:10:25 -07:00
Joe Julian b76cb7e1df Simplify phone vault screen and settings navigation 2026-04-02 21:50:36 -07:00
Joe Julian 3667fce5f4 Make phone entry pane fully scrollable 2026-04-02 21:38:51 -07:00
Joe Julian c2a4fa4087 Trim verbose group navigation copy 2026-04-02 21:32:07 -07:00
Joe Julian e4ebb2e723 Collapse phone subgroup browser after navigation 2026-04-02 09:41:33 -07:00
Joe Julian 7fc0661b32 Fix Android reopen crash and simplify group navigation 2026-04-02 07:28:55 -07:00
Joe Julian 5b6f7bb1de Fix Android local open flow 2026-04-01 21:42:19 -07:00
Joe Julian b030b69c0d Move remote preference help out of open flow 2026-04-01 19:22:56 -07:00
Joe Julian 25c972263f Integrate autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 818cb2d70e Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian c0afe96f56 Move sync defaults into vault settings 2026-04-01 18:46:10 -07:00
Joe Julian 6cfb5ea567 Stabilize settings persistence tests 2026-04-01 18:46:10 -07:00
Joe Julian ea1cf43cbf Add autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 9a920f9a5a Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian b813c8f221 Add notification feedback settings 2026-04-01 18:46:10 -07:00
Joe Julian 6f2b4d49b3 Add persisted UI settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian 7ee969fb81 Move remote preference help out of open flow 2026-04-01 17:37:06 -07:00
Joe Julian 99a581674d Align audit UI tests with empty-state model 2026-04-01 17:21:16 -07:00
Joe Julian 004a0278a7 Refine audit UI and vault settings placement 2026-04-01 17:21:16 -07:00
Joe Julian 2aa859f7cb Improve local vault open usability 2026-04-01 17:17:37 -07:00
Joe Julian 75fbd340aa Refine entry detail and edit panels 2026-04-01 17:15:58 -07:00
Joe Julian 1a8113d43b Tighten mobile locked vault hierarchy 2026-04-01 17:15:20 -07:00
Joe Julian dafc696053 Improve autofill status feedback 2026-04-01 17:13:43 -07:00
Joe Julian f205e753e8 Refine API token management UI 2026-04-01 17:12:29 -07:00
Joe Julian 3324eec9ec Improve entries navigation readability 2026-04-01 17:12:17 -07:00
Joe Julian 8c339eb309 Refine remote open lifecycle flow 2026-04-01 17:10:26 -07:00
Joe Julian 9ec8ef12b0 Clarify group navigation and management 2026-04-01 17:10:04 -07:00
Joe Julian 468b2dc933 Refine recycle bin and sync UX 2026-04-01 17:09:51 -07:00
Joe Julian a1cbec85da Clarify lifecycle loading and target selection 2026-04-01 17:00:24 -07:00
Joe Julian 7de55d1041 Avoid blocking UI during vault open and unlock 2026-04-01 16:54:16 -07:00
Joe Julian 1468bd5c5a Improve recent targets and workflow context 2026-04-01 16:43:15 -07:00
Joe Julian a2a7cb431d Refine group, editor, and admin workflows 2026-04-01 16:34:24 -07:00
Joe Julian baf3f27656 Clarify start, locked, and recycle flows 2026-04-01 16:29:53 -07:00
Joe Julian 72e67039cf Tighten navigation and admin UI 2026-04-01 16:24:11 -07:00
Joe Julian b8b4ab9e1d Refine settings and empty-state affordances 2026-04-01 15:00:46 -07:00
Joe Julian 84fea5e5d7 Simplify lifecycle and section UI 2026-04-01 14:58:56 -07:00
Joe Julian 73ff0fb77d Add UI review follow-ups to TODO 2026-04-01 14:22:53 -07:00
Joe Julian bd99b928d9 Honor alternate autofill targets from entry fields 2026-04-01 10:36:08 -07:00
Joe Julian 06ea95f0b3 Fix entry and group navigation workflows 2026-04-01 10:24:57 -07:00
Joe Julian b0721e5af1 Support Android app targets in autofill service 2026-04-01 05:56:52 -07:00
Joe Julian d9d1cf134d Disambiguate same-host Android fill matches 2026-04-01 05:21:15 -07:00
Joe Julian cc7214b880 Add Android accessibility-based Chrome form fill 2026-04-01 01:24:28 -07:00
Joe Julian 4cad54a696 Add Android autofill service packaging 2026-03-31 23:13:10 -07:00
Joe Julian 8499b4304e Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian eb73cab155 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian beefd51b15 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 0ab444b0b9 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian 867aae5ffe Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian ff7f84c386 Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 2000c27324 Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian 182b469b0b Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 27fdd77aa1 Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 88151dc780 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b2c26622e8 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 84f39b99de Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian fca121f170 Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 794eed04d4 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e9eca73336 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian 74dfe3f3d0 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian f77a185e46 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian 6a7594e128 Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 666252f18c Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian f87b3f1989 Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian caf4ec6266 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 47d0ccc7ce Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian 96ec583c7e Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 6e2760c514 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian 59905ccd98 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian 62bb20edb0 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian 4e082c345a Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 5bcd951e6a Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian c0747b62a0 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 3066442c1b Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian 4a3e52ec1c Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian daecb5ff32 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian 99113aa26b Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian 4b78a649b1 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian 2e9e2aae5f Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian db06fcd385 Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian d7026d5c0e Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian 20d5b3ba3d Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 1c4fb59960 Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian fe3fa854bb Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 37b83e654a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 761fae9b9b Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian fc9580403d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 92e8fce0e7 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian cd21cc26c9 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian fa75908552 Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 7e2e396264 Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 01e06deeef Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 97f6a34472 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian fc8630f894 Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian ef0fb7f5d9 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian 3d0a6bc43a Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian e693aab13b Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian a2f1539027 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 508fe7abc1 Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian ed1e2c3e6b Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian 2b535a90e4 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian 3f29fae12f Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian c050cb3e40 Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian cfec0c6703 Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian 00717f32ce Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian fc90e91174 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian c248b89fcd test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian 73992a95cc test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian ba945b5527 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 43fc278fd1 Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian 484448b51c Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian a708e17b50 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 17ff374be7 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian d05830335f Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 4848b09be1 Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian 5a458c0c8c Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian beda174819 Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4e67328a09 Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian c5670a9a36 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian a14101e415 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian c5e2df4ca7 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian 43d253aa21 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 6c5e9b42d3 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 5a6ba8ff57 Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian 6c1ccdad16 Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 6748d31f87 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 40fd1bfde9 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 57f6ae658d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian a857f972ea Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian f009573b4a Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian 4b4696ce30 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian afa6c8821f Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian afe9680d7a Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian 422de535af Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 44bba18149 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian e15cfb1535 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian a2a8fcbd14 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
99 changed files with 21193 additions and 1495 deletions
+116
View File
@@ -0,0 +1,116 @@
---
name: keepassgo-apk-test
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
---
# KeePassGO APK Test
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
## Use This Skill When
- Building `build/keepassgo.apk`.
- Installing or launching KeePassGO in the Android emulator.
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
- Checking whether a Gio or Android toolchain change broke runtime behavior.
## Known Working Environment
- Preferred emulator AVD: `KeepassGoAPI35`
- Package: `org.julianfamily.keepassgo`
- Activity: `org.gioui.GioActivity`
- Local APK build defaults:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-ndk`
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
- CI APK build uses:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
## Known Regression
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
- `gioui.org v0.8.0` rendered correctly in the same environment.
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
## Safety Rules
- Do not clobber the users real KeePassGO state while testing.
- For host-side validation, use isolated state such as:
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
- Prefer sanitized demo or test vaults when seeding emulator tests.
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the users existing recent-vault state.
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
2. Prefer the `KeepassGoAPI35` AVD.
3. Install with `adb install -r build/keepassgo.apk`.
4. Launch with:
```sh
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
```
5. Confirm focus before drawing conclusions:
```sh
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
```
6. Capture evidence before editing code:
screenshot
focused window
relevant `logcat`
## Validation Checklist
- APK builds successfully with `make apk`.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
## Issue-Specific Checks
### Black Screen
- Confirm the app is focused.
- Capture a screenshot and `logcat` before changing anything.
- Compare the current Gio version against the known `v0.9.0` regression history.
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
### Clipboard
- Verify behavior in the emulator, not just unit tests.
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
### File Picker Or Local Sync
- Verify the picker flow on Android itself.
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
## Report Back
When closing out work, state:
- the exact build command used
- whether the emulator session was reused or newly started
- whether install succeeded
- whether the app actually rendered
- what was verified manually in the emulator
- what remains unverified
+173
View File
@@ -0,0 +1,173 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
lint-test:
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Lint
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
- name: Test
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
build:
needs: lint-test
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Prepare dist directory
shell: bash
run: |
set -euo pipefail
mkdir -p "${DIST_DIR}"
- name: Build desktop binaries
shell: bash
run: |
set -euo pipefail
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
# Keep the CI matrix to targets this runner can build reproducibly.
for target in \
"linux amd64" \
"windows amd64" \
"windows arm64"
do
set -- ${target}
goos="$1"
goarch="$2"
ext=""
cgo_enabled=0
if [[ "${goos}" == "linux" ]]; then
cgo_enabled=1
fi
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" go build -o "${out}" .
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
make apk
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
NODE_OPTIONS: --use-system-ca
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
with:
name: keepassgo-${{ gitea.sha }}
path: |
dist/keepassgo-linux-amd64
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
retention-days: 30
- name: Publish release artifacts
if: startsWith(gitea.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
shell: bash
run: |
set -euo pipefail
python3 scripts/gitea_release.py \
--server "${GITEA_SERVER_URL}" \
--repo "${GITEA_REPOSITORY}" \
--token "${GITEA_TOKEN}" \
--tag "${GITEA_REF_NAME}" \
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
+16
View File
@@ -14,6 +14,8 @@ These instructions apply to all future work in this repository.
## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are:
`go-code-review`,
`go-concurrency`,
@@ -126,6 +128,20 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
+17 -1
View File
@@ -23,10 +23,26 @@ Installed machine prerequisites expected by this repo:
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
- JDK 17 or newer
- a working JDK install
The repo tracks `gogio` as a Go tool, so the build runs through:
```sh
go tool gogio -target android ...
```
The Android build uses the branded icon asset at:
- `assets/keepassgo-icon.png`
Note:
- Gio's Android doc currently references Java 1.8, but the Android build-tools
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
pipeline. KeePassGO is pinned to the working Gio line until that regression is
understood upstream.
+15 -2
View File
@@ -9,8 +9,8 @@ ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
.PHONY: apk
apk:
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a JDK 17+ install"; exit 1; }
apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
@@ -28,4 +28,17 @@ apk:
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \
-icon assets/keepassgo-icon.png \
.
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
+30 -2
View File
@@ -41,20 +41,48 @@ Desktop build:
go build ./...
```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`.
The repo now has automated tests for the packaging contract:
- default APK build arguments
- required Android SDK / NDK / JDK layout checks
Those are covered by normal test runs:
```bash
go test ./...
```
Install:
```bash
go install gioui.org/cmd/gogio@latest
go get -tool gioui.org/cmd/gogio@latest
```
Package:
```bash
gogio -target android .
go tool gogio -target android -icon assets/keepassgo-icon.png .
```
You will need the Android SDK and NDK installed and configured for real device or release packaging.
+115 -44
View File
@@ -6,6 +6,102 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met.
## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish.
### Primary Workflow Changes
These should remain in the main user flow rather than being hidden behind a settings gear.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
keep recent vault selection visually obvious and clearly tappable.
- Local open flow:
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
- Local open flow:
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
- Local open flow:
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
- Remote open flow:
break the remote form into clearer sections such as `Location` and `Authentication`.
- Remote open flow:
make recent remote connections easier to scan with a friendlier label than raw URL and path.
- Locked screen:
show clear vault identity and target summary so the user knows what is being unlocked.
- Entries screen:
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
- Entries screen:
make breadcrumbs compress more aggressively on phone.
- Entries screen:
improve entry-row hierarchy and selected-state contrast.
- Entries screen:
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
- Group navigation:
make the distinction between root, current group, and child groups more obvious.
- Group navigation:
separate navigation controls from group-management controls more clearly.
- Entry detail:
tighten field spacing and reduce unnecessary whitespace.
- Entry detail:
group password reveal and copy actions more clearly.
- Entry detail:
make attachments more visible and actionable.
- Entry edit:
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
- Entry edit:
make add/remove affordances for custom fields more visually obvious.
- Entry edit:
make generated-password draft state more explicit before save.
- Recycle bin:
make it visually distinct from normal entry browsing.
- API tokens:
give token list, token detail, and policy editing a clearer dedicated management surface.
- API tokens:
make policy rows easier to scan by separating effect, operation, and resource visually.
- API audit:
improve empty-state guidance and provide quick filtering by token, decision, and operation.
- Synchronize:
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
fix Android local-open ANR behavior before deeper mobile polish.
- Autofill UX:
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
### Settings Gear Candidates
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
- Vault security:
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
- Vault security:
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
- Remote preferences:
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
- UI preferences:
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
- Autofill behavior:
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
- Sync defaults:
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
- Notification preferences:
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
- Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
- Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
@@ -449,6 +545,21 @@ Exit criteria:
- Focus and accessibility states are visible and intentional.
- `go test ./...` passes.
### Segment 21: Accessibility Fill Generalization
Scope:
- Extend Android accessibility-based fill beyond the current Chrome demo path.
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
Exit criteria:
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
- `go test ./...` passes.
### Segment 17: UI Completion And Error Surfaces
Scope:
@@ -533,48 +644,8 @@ Do not treat the product as complete until all of the following are true:
## Remaining Gaps Against AGENTS.md
This section tracks requirements from `AGENTS.md` that are not fully satisfied by the current landed code, even if the segment work and tests are green.
None currently identified.
### 1. KDBX Security Settings Are Only Preserved, Not Fully Product-Configurable
Evidence:
- `docs/kdbx-compatibility.md` states that KeePassGO preserves the original opened vault's cipher and KDF selection during save.
- The same document also states:
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly.
- New vault creation still uses library default KDBX header settings.
Why this is still a gap:
- `AGENTS.md` requires support for the major KeePass-style encryption and KDF configuration choices represented in KDBX databases.
- Preserving existing settings is good, but it is weaker than allowing the product user to choose those settings for new vaults or change them explicitly for existing vaults.
Remaining work:
- Expose major KDBX cipher/KDF choices in the product UI for vault creation.
- Expose supported security-setting changes for an existing unlocked vault.
- Add behavior tests covering explicit user selection of supported cipher/KDF options.
- Update `docs/kdbx-compatibility.md` once those product-facing controls exist.
Exit criteria for this gap:
- A user can create a vault with a selected supported cipher/KDF combination through the product.
- A user can change supported cipher/KDF settings for an existing vault through the product, where the underlying library supports it.
- Tests cover those choices end to end.
### 2. Accessibility Requirement Needs Explicit Screen-Reader Review
Evidence:
- Keyboard-first behavior and focus handling exist in the landed code.
- Accessibility labels exist in `ui_accessibility.go`.
- The repository does not currently contain a documented review of what screen-reader-conscious behavior Gio can and cannot provide on the supported desktop targets.
Why this is still a gap:
- `AGENTS.md` explicitly calls for screen-reader-conscious design, not only keyboard shortcuts and focus states.
- The current code suggests intent, but the repo does not yet document a concrete accessibility support boundary or validation result.
Remaining work:
- Audit the current Gio accessibility surface on Linux and Windows for the controls used by KeePassGO.
- Document what is currently exposed, what is intentionally labeled, and what remains limited by the toolkit.
- Add targeted tests for any label/focus mapping that can be verified in-repo.
Exit criteria for this gap:
- The repo includes a documented accessibility review for current desktop targets.
- Screen-reader-conscious behavior is explicitly described rather than implied.
- Any in-repo verifiable accessibility mappings have tests.
The last explicitly tracked gaps are now closed:
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
+24
View File
@@ -0,0 +1,24 @@
<service
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
android:exported="true"
android:label="KeePassGO Autofill"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data
android:name="android.autofill"
android:resource="@xml/keepassgo_autofill_service" />
</service>
<service
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
android:exported="true"
android:label="KeePassGO Accessibility Fill"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
Binary file not shown.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:settingsActivity="" />
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,334 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillCacheStore() {
}
static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return null;
}
List<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
if (entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
private static File findCacheFile(Context context) {
List<File> candidates = new ArrayList<>();
File filesDir = context.getFilesDir();
if (filesDir != null) {
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
}
File baseDir = context.getDataDir();
if (baseDir != null) {
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
return candidate;
}
}
Log.i(TAG, "no autofill cache file in " + candidates);
return null;
}
private static List<Entry> readEntries(File cacheFile) throws IOException {
List<Entry> entries = new ArrayList<>();
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("entries".equals(name)) {
reader.beginArray();
while (reader.hasNext()) {
entries.add(readEntry(reader));
}
reader.endArray();
} else {
reader.skipValue();
}
}
reader.endObject();
}
return entries;
}
private static Entry readEntry(JsonReader reader) throws IOException {
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "title":
title = nextString(reader);
break;
case "username":
username = nextString(reader);
break;
case "password":
password = nextString(reader);
break;
case "url":
url = nextString(reader);
break;
case "host":
host = normalizeHost(nextString(reader));
break;
case "targets":
reader.beginArray();
while (reader.hasNext()) {
targets.add(nextString(reader));
}
reader.endArray();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(title, username, password, host, url, targets);
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
static final class Entry {
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
Entry(String title, String username, String password, String host, String url, List<String> targets) {
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
}
}
}
@@ -0,0 +1,195 @@
package org.julianfamily.keepassgo;
import android.accessibilityservice.AccessibilityService;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
public final class KeePassGOAccessibilityService extends AccessibilityService {
private static final String TAG = "KeePassGOA11y";
private String lastFilledSignature = "";
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
try {
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null) {
return;
}
CharSequence packageName = root.getPackageName();
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
return;
}
ChromeForm form = inspectChrome(root);
if (form == null || form.passwordField == null) {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.url);
return;
}
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
if (signature.equals(lastFilledSignature)) {
return;
}
boolean filled = false;
if (form.usernameField != null) {
filled |= setNodeText(form.usernameField, entry.username);
}
filled |= setNodeText(form.passwordField, entry.password);
if (filled) {
lastFilledSignature = signature;
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
}
} catch (Exception err) {
Log.e(TAG, "accessibility fill failed", err);
}
}
@Override
public void onInterrupt() {
Log.i(TAG, "accessibility service interrupted");
}
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm();
walk(root, editables, form);
if (form.url.isEmpty()) {
return null;
}
if (form.passwordField == null) {
for (AccessibilityNodeInfo node : editables) {
if (isPasswordNode(node)) {
form.passwordField = node;
break;
}
}
}
if (form.usernameField == null && form.passwordField != null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
if (isUsernameNode(node)) {
form.usernameField = node;
break;
}
}
if (form.usernameField == null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
form.usernameField = node;
break;
}
}
}
return form;
}
private static void walk(
AccessibilityNodeInfo node,
List<AccessibilityNodeInfo> editables,
ChromeForm form
) {
if (node == null) {
return;
}
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
CharSequence text = node.getText();
if (text != null) {
form.url = text.toString().trim();
}
}
if (node.isEditable()) {
editables.add(node);
if (form.passwordField == null && isPasswordNode(node)) {
form.passwordField = node;
} else if (form.usernameField == null && isUsernameNode(node)) {
form.usernameField = node;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
walk(child, editables, form);
}
}
}
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
if (node.isPassword()) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
String lowerTerm = term.toLowerCase();
return containsLower(node.getHintText(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|| containsLower(node.getPaneTitle(), lowerTerm);
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
if (node == null || !node.isEditable() || value == null) {
return false;
}
Bundle args = new Bundle();
args.putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
value
);
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
}
private static String nodeKey(AccessibilityNodeInfo node) {
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null) {
return viewID.toString();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return String.valueOf(node.getUniqueId());
}
CharSequence hint = node.getHintText();
if (hint != null) {
return hint.toString();
}
return String.valueOf(node.hashCode());
}
private static final class ChromeForm {
String url = "";
AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField;
}
}
@@ -0,0 +1,215 @@
package org.julianfamily.keepassgo;
import android.app.assist.AssistStructure;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
import android.service.autofill.FillCallback;
import android.service.autofill.FillContext;
import android.service.autofill.FillRequest;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveRequest;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import java.util.List;
public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill";
private static final String APP_SCHEME = "androidapp://";
@Override
public void onConnected() {
super.onConnected();
Log.i(TAG, "service connected");
}
@Override
public void onDisconnected() {
Log.i(TAG, "service disconnected");
super.onDisconnected();
}
@Override
public void onFillRequest(
FillRequest request,
CancellationSignal cancellationSignal,
FillCallback callback
) {
try {
Log.i(TAG, "fill request flags=" + request.getFlags());
List<FillContext> contexts = request.getFillContexts();
if (contexts.isEmpty()) {
Log.i(TAG, "fill request had no contexts");
callback.onSuccess(null);
return;
}
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields();
ParsedTarget target = parseWindow(structure, fields);
Log.i(
TAG,
"parsed target=" + target.matchTarget
+ " package=" + target.packageName
+ " usernameId=" + fields.usernameId
+ " passwordId=" + fields.passwordId
);
if (fields.passwordId == null) {
Log.i(TAG, "no password field found");
callback.onSuccess(null);
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
if (entry == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
Log.e(TAG, "fill request failed", err);
callback.onFailure(err.getMessage());
}
}
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
Log.i(TAG, "save request");
callback.onSuccess();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
for (int i = 0; i < windowCount; i++) {
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
String next = parseNode(root, fields);
if (!next.isEmpty()) {
domain = next;
}
}
String packageName = "";
if (structure.getActivityComponent() != null) {
packageName = structure.getActivityComponent().getPackageName();
}
if (!domain.isEmpty()) {
return new ParsedTarget(domain, packageName);
}
if (packageName != null && !packageName.isEmpty()) {
return new ParsedTarget(APP_SCHEME + packageName, packageName);
}
return new ParsedTarget("", "");
}
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
String domain = "";
if (node.getWebDomain() != null) {
domain = node.getWebDomain();
}
AutofillId id = node.getAutofillId();
if (id != null) {
if (fields.passwordId == null && isPasswordNode(node)) {
fields.passwordId = id;
} else if (fields.usernameId == null && isUsernameNode(node)) {
fields.usernameId = id;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
String childDomain = parseNode(node.getChildAt(i), fields);
if (!childDomain.isEmpty()) {
domain = childDomain;
}
}
return domain;
}
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
int inputType = node.getInputType();
int variation = inputType & InputType.TYPE_MASK_VARIATION;
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
int autofillType = node.getAutofillType();
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
return false;
}
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
String lowerTerm = term.toLowerCase();
String[] hints = node.getAutofillHints();
if (hints != null) {
for (String hint : hints) {
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
return true;
}
}
}
if (containsLower(node.getHint(), lowerTerm)
|| containsLower(node.getIdEntry(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)) {
return true;
}
return false;
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static final class ParsedFields {
AutofillId usernameId;
AutofillId passwordId;
}
private static final class ParsedTarget {
final String matchTarget;
final String packageName;
ParsedTarget(String matchTarget, String packageName) {
this.matchTarget = matchTarget;
this.packageName = packageName;
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"errors"
"fmt"
"net"
"strings"
"sync"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
)
type DirtyProvider func() bool
type Host struct {
server *Server
grpcServer *grpc.Server
listener net.Listener
lifecycle lifecycleBackend
dirty DirtyProvider
mu sync.Mutex
lastModel vault.Model
started bool
listenAddr string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
addr = strings.TrimSpace(addr)
if addr == "" || strings.EqualFold(addr, "off") {
return nil, nil
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
}
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{
server: service,
grpcServer: server,
listener: listener,
lifecycle: lifecycle,
dirty: dirty,
listenAddr: listener.Addr().String(),
started: true,
}
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
_ = listener.Close()
server.Stop()
return nil, err
}
go func() {
_ = server.Serve(listener)
}()
return host, nil
}
func (h *Host) Address() string {
if h == nil {
return ""
}
return h.listenAddr
}
func (h *Host) Server() *Server {
if h == nil {
return nil
}
return h.server
}
func (h *Host) Stop() error {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
if !h.started {
return nil
}
h.started = false
h.grpcServer.Stop()
return h.listener.Close()
}
func (h *Host) SyncFromLifecycle() error {
if h == nil || h.lifecycle == nil || h.server == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
model, err := h.lifecycle.Current()
locked := false
switch {
case err == nil:
h.lastModel = model
case errors.Is(err, session.ErrLocked):
locked = true
default:
return err
}
dirty := false
if h.dirty != nil {
dirty = h.dirty()
}
h.server.SetSessionState(h.lastModel, locked, dirty)
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"context"
"net"
"testing"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Parallel()
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
defer func() { _ = host.Stop() }()
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", host.Address())
}),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
defer func() { _ = conn.Close() }()
client := keepassgov1.NewVaultServiceClient(conn)
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
if !statusResp.Dirty {
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
}
if err := lifecycle.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if err := host.SyncFromLifecycle(); err != nil {
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
}
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
}
}
+374 -86
View File
@@ -8,7 +8,11 @@ import (
"slices"
"strings"
"sync"
"time"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
@@ -31,6 +35,8 @@ type Server struct {
lifecycle lifecycleBackend
profiles map[string]passwords.Profile
clipboard clipboard.Writer
approvals *apiapproval.Broker
audit *apiaudit.Log
}
type lifecycleBackend interface {
@@ -42,11 +48,18 @@ type lifecycleBackend interface {
Unlock(vault.MasterKey) error
}
type modelReplaceableLifecycle interface {
lifecycleBackend
Replace(vault.Model)
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
profiles: profiles,
clipboard: clipboardWriter,
approvals: apiapproval.NewBroker(30 * time.Second),
audit: apiaudit.New(200),
}
}
@@ -56,6 +69,26 @@ func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Pro
return server
}
func (s *Server) ApprovalBroker() *apiapproval.Broker {
return s.approvals
}
func (s *Server) AuditLog() *apiaudit.Log {
return s.audit
}
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
return s.approvals.Resolve(id, outcome)
}
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
s.locked = locked
s.dirty = dirty
}
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -190,23 +223,25 @@ func mapLifecycleError(operation string, err error) error {
}
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
return nil, err
}
model = visibleModel(model)
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := s.model.Search(req.GetQuery())
results := model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
}
} else {
entries = s.model.EntriesInPath(req.GetPath())
entries = model.EntriesInPath(req.GetPath())
}
resp := &keepassgov1.ListEntriesResponse{
@@ -219,23 +254,27 @@ func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequ
return resp, nil
}
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
return nil, err
}
return &keepassgov1.ListGroupsResponse{
Names: s.model.ChildGroups(req.GetPath()),
Names: visibleModel(model).ChildGroups(req.GetPath()),
}, nil
}
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -245,10 +284,13 @@ func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequ
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -264,10 +306,13 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -287,12 +332,15 @@ func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequ
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
if s.locked {
@@ -306,13 +354,19 @@ func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequ
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if entry, err := findEntryByID(model, req.GetId()); err == nil {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.DeleteEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
@@ -325,21 +379,27 @@ func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequ
return &keepassgov1.DeleteEntryResponse{}, nil
}
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var restored vault.Entry
for _, entry := range s.model.RecycleBin {
for _, entry := range model.RecycleBin {
if entry.ID == req.GetId() {
restored = entry
break
}
}
if restored.ID != "" {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RestoreEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
@@ -352,18 +412,19 @@ func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRe
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
}
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetId())
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
resp := &keepassgov1.ListEntryHistoryResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
@@ -374,14 +435,21 @@ func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryH
return resp, nil
}
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
@@ -389,7 +457,7 @@ func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.Restore
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
}
entry, err := findEntryByID(s.model, req.GetId())
entry, err = findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
@@ -477,18 +545,19 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
names := make([]string, 0, len(entry.Attachments))
for name := range entry.Attachments {
@@ -499,14 +568,21 @@ func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachm
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
}
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
@@ -522,18 +598,19 @@ func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAtta
return &keepassgov1.UploadAttachmentResponse{}, nil
}
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
content, ok := entry.Attachments[req.GetName()]
if !ok {
@@ -545,14 +622,21 @@ func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.Download
}, nil
}
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
@@ -572,15 +656,18 @@ func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAtta
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
s.mu.RLock()
model := s.model
locked := s.locked
s.mu.RUnlock()
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil {
return nil, err
}
service := clipboard.Service{Writer: s.clipboard}
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
@@ -597,10 +684,14 @@ func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFie
return &keepassgov1.CopyEntryFieldResponse{}, nil
}
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.authenticateRequest(ctx); err != nil {
return nil, err
}
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -665,27 +756,224 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
func visibleModel(model vault.Model) vault.Model {
out := model
out.Entries = nil
for _, entry := range model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err == nil && ok && token.ID != "" {
continue
}
out.Entries = append(out.Entries, entry)
}
out.Groups = nil
for _, path := range model.Groups {
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
continue
}
out.Groups = append(out.Groups, path)
}
return out
}
func (s *Server) snapshotModel() (vault.Model, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.model, s.locked
}
var timeNow = func() time.Time { return time.Now().UTC() }
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
}
const prefix = "Bearer "
if !strings.HasPrefix(values[0], prefix) {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
}
s.mu.RLock()
tokens, err := apitokens.Entries(s.model)
s.mu.RUnlock()
if err != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err)
}
token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow())
if err != nil {
switch err {
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
}
}
return token, nil
}
func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
}
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
}
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) {
case apitokens.DecisionAllow:
return token, nil
case apitokens.DecisionDeny:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
case apitokens.DecisionPrompt:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalRequested,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
result, err := s.approvals.Request(ctx, token, op, resource)
if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
}
}
switch {
case err == nil:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalAllowed,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalDenied,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalCanceled,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalTimedOut,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled):
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
case errors.Is(err, context.DeadlineExceeded):
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
}
default:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
}
}
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, entry := range s.model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err != nil || !ok || token.ID != tokenID {
continue
}
if !hasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
s.model.Entries[i] = token.Entry(entry.Path)
s.dirty = true
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
return nil
}
return status.Error(codes.NotFound, "api token entry not found")
}
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
return true
}
}
return false
}
func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) {
case clipboard.TargetUsername:
return apitokens.OperationCopyUsername
case clipboard.TargetURL:
return apitokens.OperationCopyURL
default:
return apitokens.OperationCopyPassword
}
}
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
switch info.FullMethod {
case "/keepassgo.v1.VaultService/GetSessionStatus",
"/keepassgo.v1.VaultService/OpenVault",
"/keepassgo.v1.VaultService/OpenRemoteVault",
"/keepassgo.v1.VaultService/SaveVault",
"/keepassgo.v1.VaultService/LockVault",
"/keepassgo.v1.VaultService/UnlockVault":
if _, err := server.authenticateRequest(ctx); err != nil {
return nil, err
}
}
values := md.Get("authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization")
}
if values[0] != "Bearer "+expectedToken {
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
}
return handler(ctx, req)
}
}
+411 -100
View File
@@ -3,10 +3,16 @@ package api
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"net"
"os"
"testing"
"time"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
@@ -32,6 +38,243 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
}
}
func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
t.Parallel()
expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC)
token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
token.Entry([]string{"Root", "API Tokens"}),
},
})
defer cleanup()
oldNow := timeNow
timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
defer func() { timeNow = oldNow }()
_, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated)
}
}
func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
),
},
})
defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if pending.Operation != apitokens.OperationListEntries {
t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow) error = %v", err)
}
resp := <-respCh
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)
}
}
func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil {
t.Fatalf("ResolveApproval(deny permanent) error = %v", err)
}
if err := <-errCh; status.Code(err) != codes.PermissionDenied {
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
service.mu.RLock()
tokens, err := apitokens.Entries(service.model)
service.mu.RUnlock()
if err != nil {
t.Fatalf("Entries() error = %v", err)
}
decision := apitokens.Evaluate(tokens[0], apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
if decision != apitokens.DecisionDeny {
t.Fatalf("Evaluate() after permanent deny = %q, want %q", decision, apitokens.DecisionDeny)
}
}
func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
t.Fatalf("ResolveApproval(cancel) error = %v", err)
}
if err := <-errCh; status.Code(err) != codes.Unauthenticated {
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
}
func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(20 * time.Millisecond)
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if status.Code(err) != codes.DeadlineExceeded {
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.DeadlineExceeded)
}
}
func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil {
t.Fatalf("ResolveApproval(allow permanent) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
events := service.AuditLog().Events()
if len(events) < 2 {
t.Fatalf("len(AuditLog().Events()) = %d, want at least 2", len(events))
}
if events[0].Type != apiaudit.EventApprovalAllowed || events[1].Type != apiaudit.EventApprovalRequested {
t.Fatalf("AuditLog().Events() = %#v, want allowed then requested", events[:2])
}
}
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
@@ -39,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -52,7 +295,7 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
@@ -108,7 +351,7 @@ func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: lifecycle.unlockPassword,
@@ -182,7 +425,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: "correct horse battery staple",
@@ -211,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "jjulian",
Username: "rustyryan",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
@@ -228,7 +471,7 @@ func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
testCases := []struct {
name string
@@ -350,7 +593,7 @@ func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
err := tt.call(client, ctx)
if status.Code(err) != tt.want {
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
@@ -365,7 +608,7 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
@@ -375,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -389,7 +632,7 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
@@ -436,7 +679,7 @@ func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
@@ -472,7 +715,7 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if err != nil {
t.Fatalf("GeneratePassword() error = %v", err)
@@ -489,7 +732,7 @@ func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
@@ -502,9 +745,9 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
client, clipboardWriter, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server",
Id: "vault-console",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
@@ -521,14 +764,14 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "ha-codex",
Title: "Home Assistant (Codex)",
Id: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
Url: "https://lights.julianfamily.org",
Url: "https://surveillance.crew.example.invalid",
Fields: map[string]string{
"X-Role": "lights-admin",
},
@@ -539,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -565,8 +808,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -579,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
}
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
if err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -593,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
}
}
@@ -604,7 +847,7 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
@@ -620,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Id: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
Url: "https://www.dynadot.com",
Url: "https://bellagio.example.invalid",
Fields: map[string]string{
"Environment": "staging",
},
@@ -636,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -659,7 +902,7 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{
Id: "website-login",
@@ -712,8 +955,8 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
}
@@ -722,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server",
Id: "vault-console",
HistoryIndex: 0,
})
if err != nil {
@@ -739,18 +982,18 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
ctx := tokenContext(defaultTestTokenSecret)
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
Content: uploaded,
}); err != nil {
t.Fatalf("UploadAttachment() error = %v", err)
}
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -759,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
})
if err != nil {
@@ -770,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
}); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err)
}
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -785,44 +1028,72 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
}
const defaultTestTokenSecret = "test-token"
func tokenContext(secret string) context.Context {
return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret)
}
func hashSecretForTest(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry {
t.Helper()
token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
token.Policies = append([]apitokens.PolicyRule(nil), rules...)
return token.Entry([]string{"Root", "API Tokens"})
}
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServer(
vault.Model{
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "git-server-h1",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-0",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
},
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.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"}}},
),
},
Templates: []vault.Entry{
{
@@ -839,45 +1110,23 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Path: []string{"Templates"},
},
},
},
passwords.DefaultProfiles(),
clipboardWriter,
))
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
}
return newTestClientForModel(t, model)
}
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
client, clipboardWriter, _, cleanup := newTestHarnessForModel(t, model)
return client, clipboardWriter, cleanup
}
func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
lifecycle.model,
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
))
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
_ = server.Serve(listener)
@@ -898,7 +1147,50 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
}
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
client, clipboardWriter, _, cleanup := newTestHarnessWithLifecycle(t, lifecycle)
return client, clipboardWriter, cleanup
}
func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{}
model := lifecycle.model
model.Entries = append(model.Entries, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, 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"}}},
))
lifecycle.model = model
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
}
type memoryClipboardWriter struct {
@@ -910,6 +1202,21 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
return nil
}
func waitForServerPendingApproval(t *testing.T, server *Server, want int) []apiapproval.Request {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
pending := server.ApprovalBroker().Pending()
if len(pending) == want {
return pending
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("server pending approvals never reached len %d", want)
return nil
}
type stubLifecycle struct {
model vault.Model
openPath string
@@ -984,3 +1291,7 @@ func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
s.locked = false
return nil
}
func (s *stubLifecycle) Replace(model vault.Model) {
s.model = model
}
+215
View File
@@ -0,0 +1,215 @@
package apiapproval
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
)
type Outcome string
const (
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
)
type Request struct {
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
RequestedAt time.Time
}
type Result struct {
Outcome Outcome
Rule *apitokens.PolicyRule
}
type Broker struct {
mu sync.Mutex
pending map[string]*pendingRequest
timeout time.Duration
now func() time.Time
nextID func() string
}
type pendingRequest struct {
request Request
done chan Outcome
}
type idGenerator struct {
mu sync.Mutex
counter int
}
func NewBroker(timeout time.Duration) *Broker {
gen := &idGenerator{}
return &Broker{
pending: map[string]*pendingRequest{},
timeout: timeout,
now: func() time.Time {
return time.Now().UTC()
},
nextID: func() string {
return gen.Next()
},
}
}
func (g *idGenerator) Next() string {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
return "approval-" + strconv.Itoa(g.counter)
}
func (b *Broker) Pending() []Request {
b.mu.Lock()
defer b.mu.Unlock()
requests := make([]Request, 0, len(b.pending))
for _, pending := range b.pending {
requests = append(requests, pending.request)
}
slices.SortFunc(requests, func(a, c Request) int {
switch {
case a.RequestedAt.Before(c.RequestedAt):
return -1
case a.RequestedAt.After(c.RequestedAt):
return 1
case a.ID < c.ID:
return -1
case a.ID > c.ID:
return 1
default:
return 0
}
})
return requests
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrRequestTimedOut
}
pending := &pendingRequest{
request: Request{
ID: b.nextID(),
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
RequestedAt: b.now(),
},
done: make(chan Outcome, 1),
}
b.mu.Lock()
b.pending[pending.request.ID] = pending
b.mu.Unlock()
defer func() {
b.mu.Lock()
delete(b.pending, pending.request.ID)
b.mu.Unlock()
}()
timer := time.NewTimer(b.timeout)
defer timer.Stop()
select {
case outcome := <-pending.done:
result, err := resultForOutcome(pending.request, outcome)
if err != nil {
return result, err
}
return result, nil
case <-timer.C:
return Result{}, ErrRequestTimedOut
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
b.mu.Lock()
pending, ok := b.pending[id]
b.mu.Unlock()
if !ok {
return Request{}, nil, ErrRequestNotFound
}
rule, err := RuleFromDecision(pending.request, outcome)
if err != nil {
return Request{}, nil, err
}
select {
case pending.done <- outcome:
default:
}
return pending.request, rule, nil
}
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
rule, err := RuleFromDecision(request, outcome)
if err != nil {
return Result{}, err
}
result := Result{Outcome: outcome, Rule: rule}
switch outcome {
case OutcomeAllowOnce, OutcomeAllowPermanent:
return result, nil
case OutcomeDenyOnce, OutcomeDenyPermanent:
return result, ErrRequestDenied
case OutcomeCancel:
return result, ErrRequestCanceled
default:
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
switch outcome {
case OutcomeAllowPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeDenyPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectDeny,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
return nil, nil
default:
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
+134
View File
@@ -0,0 +1,134 @@
package apiapproval
import (
"context"
"errors"
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
resultCh := make(chan Result, 1)
errCh := make(chan error, 1)
go func() {
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
resultCh <- result
errCh <- err
}()
waitForPending(t, broker, 1)
pending := broker.Pending()
if len(pending) != 1 {
t.Fatalf("Pending() len = %d, want 1", len(pending))
}
if pending[0].TokenID != "token-1" {
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
}
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
result := <-resultCh
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
if result.Outcome != OutcomeAllowOnce {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
}
if result.Rule != nil {
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
}
}
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
reqDone := make(chan struct{})
var result Result
var err error
go func() {
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
close(reqDone)
}()
waitForPending(t, broker, 1)
pending := broker.Pending()[0]
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
if resolveErr != nil {
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
}
if request.ID != pending.ID {
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
}
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
}
<-reqDone
if !errors.Is(err, ErrRequestDenied) {
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
}
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
}
if result.Outcome != OutcomeDenyPermanent {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
}
}
func TestBrokerSupportsCancellation(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
t.Fatalf("Resolve(cancel) error = %v", err)
}
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
}
}
func TestBrokerTimesOutPendingRequests(t *testing.T) {
t.Parallel()
broker := NewBroker(10 * time.Millisecond)
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrRequestTimedOut) {
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
}
}
func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := len(broker.Pending()); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("Pending() never reached len %d", want)
}
+80
View File
@@ -0,0 +1,80 @@
package apiaudit
import (
"slices"
"sync"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
type EventType string
const (
EventApprovalRequested EventType = "approval_requested"
EventApprovalAllowed EventType = "approval_allowed"
EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected"
)
type Event struct {
Type EventType
At time.Time
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
Message string
}
type Log struct {
mu sync.Mutex
max int
now func() time.Time
events []Event
}
func New(max int) *Log {
if max < 1 {
max = 1
}
return &Log{
max: max,
now: func() time.Time {
return time.Now().UTC()
},
}
}
func (l *Log) Record(event Event) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if event.At.IsZero() {
event.At = l.now()
}
l.events = append([]Event{event}, l.events...)
if len(l.events) > l.max {
l.events = l.events[:l.max]
}
}
func (l *Log) Events() []Event {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
return slices.Clone(l.events)
}
+68
View File
@@ -0,0 +1,68 @@
package apiaudit
import (
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
t.Parallel()
log := New(2)
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
events := log.Events()
if len(events) != 2 {
t.Fatalf("len(Events()) = %d, want 2", len(events))
}
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
}
}
func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventApprovalRequested,
TokenID: "token-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
Message: "prompted for access",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
}
}
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
+335
View File
@@ -0,0 +1,335 @@
package apitokens
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/vault"
)
const (
EntryTypeAPIToken = "api-token"
FieldType = "KeePassGO-Type"
FieldTokenID = "KeePassGO-API-Token-ID"
FieldClientName = "KeePassGO-API-Client-Name"
FieldCreatedAt = "KeePassGO-API-Created-At"
FieldExpiresAt = "KeePassGO-API-Expires-At"
FieldDisabled = "KeePassGO-API-Disabled"
FieldRevokedAt = "KeePassGO-API-Revoked-At"
FieldSecretHash = "KeePassGO-API-Secret-Hash"
FieldPolicies = "KeePassGO-API-Policies"
)
var (
ErrNotAToken = errors.New("entry is not an api token")
ErrInvalidToken = errors.New("invalid api token")
ErrExpiredToken = errors.New("expired api token")
ErrDisabledToken = errors.New("disabled api token")
ErrTokenNotFound = errors.New("api token not found")
)
var EntryPath = []string{"Root", "API Tokens"}
type Effect string
type Operation string
type ResourceKind string
type Decision string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
ResourceGroup ResourceKind = "group"
ResourceEntry ResourceKind = "entry"
DecisionAllow Decision = "allow"
DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationManageVault Operation = "manage_vault"
)
type Resource struct {
Kind ResourceKind `json:"kind"`
Path []string `json:"path,omitempty"`
EntryID string `json:"entry_id,omitempty"`
}
type PolicyRule struct {
Effect Effect `json:"effect"`
Operation Operation `json:"operation"`
Resource Resource `json:"resource"`
}
type Token struct {
ID string
Name string
ClientName string
SecretHash string
CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
Disabled bool
Policies []PolicyRule
}
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
id, _, err := newSecret()
if err != nil {
return Token{}, "", err
}
return Token{
ID: id,
Name: strings.TrimSpace(name),
ClientName: strings.TrimSpace(clientName),
SecretHash: hashed,
CreatedAt: now.UTC(),
ExpiresAt: cloneTime(expiresAt),
}, clear, nil
}
func Rotate(token Token, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
token.SecretHash = hashed
token.Disabled = false
token.RevokedAt = nil
if token.CreatedAt.IsZero() {
token.CreatedAt = now.UTC()
}
return token, clear, nil
}
func Disable(token Token) Token {
token.Disabled = true
return token
}
func Revoke(token Token, when time.Time) Token {
token.Disabled = true
t := when.UTC()
token.RevokedAt = &t
return token
}
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
hashed := hashSecret(presentedSecret)
for _, token := range tokens {
if token.SecretHash != hashed {
continue
}
if token.Disabled || token.RevokedAt != nil {
return Token{}, ErrDisabledToken
}
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
return Token{}, ErrExpiredToken
}
return token, nil
}
return Token{}, ErrInvalidToken
}
func Evaluate(token Token, operation Operation, resource Resource) Decision {
decision := DecisionPrompt
for _, rule := range token.Policies {
if rule.Operation != operation {
continue
}
if !matches(rule.Resource, resource) {
continue
}
if rule.Effect == EffectDeny {
return DecisionDeny
}
if rule.Effect == EffectAllow {
decision = DecisionAllow
}
}
return decision
}
func Entries(model vault.Model) ([]Token, error) {
var out []Token
for _, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return nil, err
}
if ok {
out = append(out, token)
}
}
slices.SortFunc(out, func(a, b Token) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
default:
return strings.Compare(a.ID, b.ID)
}
})
return out, nil
}
func Find(model vault.Model, id string) (Token, error) {
tokens, err := Entries(model)
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if token.ID == id {
return token, nil
}
}
return Token{}, ErrTokenNotFound
}
func Upsert(model *vault.Model, token Token) {
model.UpsertEntry(token.Entry(EntryPath))
model.CreateGroup([]string{"Root"}, "API Tokens")
}
func Delete(model *vault.Model, id string) error {
for i, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return err
}
if ok && token.ID == id {
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
return nil
}
}
return ErrTokenNotFound
}
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
if entry.Fields[FieldType] != EntryTypeAPIToken {
return Token{}, false, nil
}
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
if err != nil {
return Token{}, true, fmt.Errorf("parse created at: %w", err)
}
var expiresAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
}
expiresAt = &t
}
var revokedAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
}
revokedAt = &t
}
policies := []PolicyRule{}
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
return Token{}, true, fmt.Errorf("parse policies: %w", err)
}
}
return Token{
ID: entry.Fields[FieldTokenID],
Name: entry.Title,
ClientName: entry.Fields[FieldClientName],
SecretHash: entry.Fields[FieldSecretHash],
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RevokedAt: revokedAt,
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
Policies: policies,
}, true, nil
}
func (t Token) Entry(path []string) vault.Entry {
fields := map[string]string{
FieldType: EntryTypeAPIToken,
FieldTokenID: t.ID,
FieldClientName: t.ClientName,
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
FieldSecretHash: t.SecretHash,
}
if t.ExpiresAt != nil {
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
}
if t.RevokedAt != nil {
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
}
if len(t.Policies) > 0 {
data, _ := json.Marshal(t.Policies)
fields[FieldPolicies] = string(data)
}
return vault.Entry{
ID: t.ID,
Title: t.Name,
Username: t.ClientName,
Path: slices.Clone(path),
Fields: fields,
}
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func newSecret() (string, string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate secret: %w", err)
}
clear := base64.RawURLEncoding.EncodeToString(buf)
return clear, hashSecret(clear), nil
}
func cloneTime(in *time.Time) *time.Time {
if in == nil {
return nil
}
t := in.UTC()
return &t
}
func matches(rule, resource Resource) bool {
switch rule.Kind {
case ResourceEntry:
return rule.EntryID != "" && rule.EntryID == resource.EntryID
case ResourceGroup:
if len(rule.Path) > len(resource.Path) {
return false
}
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
default:
return false
}
}
+282
View File
@@ -0,0 +1,282 @@
package apitokens
import (
"slices"
"testing"
"time"
"git.julianfamily.org/keepassgo/vault"
)
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
token := Token{
ID: "token-1",
Name: "Browser Connector",
ClientName: "browser-extension",
SecretHash: "deadbeef",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
ExpiresAt: &expiresAt,
Disabled: true,
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
},
}
entry := token.Entry([]string{"Root", "API Tokens"})
if entry.Fields[FieldType] != EntryTypeAPIToken {
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
}
got, ok, err := TokenFromEntry(entry)
if err != nil {
t.Fatalf("TokenFromEntry() error = %v", err)
}
if !ok {
t.Fatal("TokenFromEntry() ok = false, want true")
}
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
}
if len(got.Policies) != 2 {
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
}
}
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Ordinary Entry"},
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
},
}
got, err := Entries(model)
if err != nil {
t.Fatalf("Entries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(Entries()) = %d, want 2", len(got))
}
if got[0].Name != "Browser" || got[1].Name != "CLI" {
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
}
}
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
t.Parallel()
model := vault.Model{}
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "grpc-cli",
SecretHash: "hash-1",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
}
Upsert(&model, token)
if len(model.Entries) != 1 {
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
}
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
}
token.ClientName = "rotated-client"
token.Disabled = true
Upsert(&model, token)
got, err := Find(model, "token-1")
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if got.ClientName != "rotated-client" || !got.Disabled {
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
}
}
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
{ID: "entry-1", Title: "Regular Entry"},
},
}
if err := Delete(&model, "token-1"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
}
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
}
}
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if token.ID == "" || secret == "" {
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
}
if token.SecretHash == secret {
t.Fatal("SecretHash should not equal cleartext secret")
}
if token.Disabled {
t.Fatal("Disabled = true, want false after Issue")
}
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("Rotate() error = %v", err)
}
if rotated.ID != token.ID {
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
}
if newSecret == secret {
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
}
if rotated.SecretHash == token.SecretHash {
t.Fatal("Rotate() left SecretHash unchanged")
}
disabled := Disable(rotated)
if !disabled.Disabled {
t.Fatal("Disable() did not set Disabled")
}
revoked := Revoke(disabled, now.Add(2*time.Hour))
if !revoked.Disabled {
t.Fatal("Revoke() should leave token disabled")
}
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
}
}
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(expired) error = %v", err)
}
expiredAt := now.Add(-time.Minute)
expired.ExpiresAt = &expiredAt
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(disabled) error = %v", err)
}
disabled = Disable(disabled)
tokens := []Token{expired, disabled, valid}
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
}
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
}
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
}
got, err := Authenticate(tokens, secret, now)
if err != nil {
t.Fatalf("Authenticate(valid) error = %v", err)
}
if got.ID != valid.ID {
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
}
}
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
},
}
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
if decision != DecisionAllow {
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
if decision != DecisionDeny {
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
if decision != DecisionPrompt {
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
}
}
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
},
}
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
if allowed != DecisionAllow || denied != DecisionDeny {
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
}
}
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "cli",
SecretHash: "hash",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
Policies: []PolicyRule{
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
},
}
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
if err != nil || !ok {
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
}
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
}) {
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
}
}
+208 -1
View File
@@ -5,7 +5,10 @@ import (
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
@@ -21,6 +24,8 @@ const (
SectionEntries Section = ""
SectionTemplates Section = "templates"
SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
)
type CurrentSession interface {
@@ -56,6 +61,7 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
@@ -81,8 +87,19 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings
}
type ApprovalManager interface {
Pending() []apiapproval.Request
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
}
type State struct {
Session CurrentSession
Approvals ApprovalManager
Section Section
CurrentPath []string
SearchQuery string
@@ -92,6 +109,161 @@ type State struct {
ErrorMessage string
}
func (s *State) PendingApprovals() []apiapproval.Request {
if s.Approvals == nil {
return nil
}
return s.Approvals.Pending()
}
func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
if s.Approvals == nil {
return fmt.Errorf("approval manager is not configured")
}
_, _, err := s.Approvals.Resolve(id, outcome)
return err
}
func (s *State) APITokens() ([]apitokens.Token, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
return apitokens.Entries(model)
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
}
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return apitokens.Token{}, "", err
}
token, err := apitokens.Find(model, id)
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
}
func (s *State) UpsertAPIToken(token apitokens.Token) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) DisableAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Disable(token))
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) RevokeAPIToken(id string, when time.Time) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Revoke(token, when))
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) DeleteAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := apitokens.Delete(&model, id); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
security, ok := s.Session.(SecurityConfigurableSession)
if !ok {
return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings")
}
return security.SecuritySettings(), nil
}
func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
security, ok := s.Session.(SecurityConfigurableSession)
if !ok {
return fmt.Errorf("session does not expose security settings")
}
if err := security.ConfigureSecurity(settings); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) ShowSection(section Section) {
s.Section = section
s.CurrentPath = nil
@@ -131,7 +303,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
}
if s.Section == SectionEntries {
return model.EntriesInPath(s.CurrentPath), nil
return entriesInPath(model.Entries, s.CurrentPath), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
@@ -204,6 +376,8 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit:
return nil
default:
return slices.Clone(model.Entries)
}
@@ -549,6 +723,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil
}
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
@@ -664,6 +850,27 @@ func (s *State) CreateGroup(name string) error {
return nil
}
func (s *State) MoveCurrentGroup(parent []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
current := append([]string(nil), s.CurrentPath...)
if err := model.MoveGroup(current, parent); err != nil {
return err
}
session.Replace(model)
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession)
if !ok {
+328 -82
View File
@@ -4,7 +4,10 @@ import (
"errors"
"slices"
"testing"
"time"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
@@ -17,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
@@ -36,8 +39,128 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(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"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
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)
}
}
func TestPendingApprovalsReturnsManagerRequests(t *testing.T) {
t.Parallel()
state := State{
Approvals: &stubApprovalManager{
pending: []apiapproval.Request{
{ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries},
},
},
}
got := state.PendingApprovals()
if len(got) != 1 || got[0].ID != "approval-1" {
t.Fatalf("PendingApprovals() = %#v, want approval-1", got)
}
}
func TestResolveApprovalDelegatesToManager(t *testing.T) {
t.Parallel()
manager := &stubApprovalManager{}
state := State{Approvals: manager}
if err := state.ResolveApproval("approval-1", apiapproval.OutcomeAllowPermanent); err != nil {
t.Fatalf("ResolveApproval() error = %v", err)
}
if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeAllowPermanent {
t.Fatalf("ResolveApproval() delegated (%q, %q), want (approval-1, allow-permanent)", manager.lastID, manager.lastOutcome)
}
}
func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{Session: session}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(24 * time.Hour)
issued, secret, err := state.IssueAPIToken("CLI", "grpc-cli", &expiresAt, now)
if err != nil {
t.Fatalf("IssueAPIToken() error = %v", err)
}
if issued.ID == "" || secret == "" {
t.Fatalf("IssueAPIToken() = %#v, %q, want non-empty id and secret", issued, secret)
}
tokens, err := state.APITokens()
if err != nil {
t.Fatalf("APITokens() error = %v", err)
}
if len(tokens) != 1 || tokens[0].ID != issued.ID {
t.Fatalf("APITokens() = %#v, want issued token", tokens)
}
rotated, rotatedSecret, err := state.RotateAPIToken(issued.ID, now.Add(time.Hour))
if err != nil {
t.Fatalf("RotateAPIToken() error = %v", err)
}
if rotated.ID != issued.ID || rotatedSecret == "" || rotatedSecret == secret {
t.Fatalf("RotateAPIToken() = %#v, %q, want same id and new secret", rotated, rotatedSecret)
}
if err := state.DisableAPIToken(issued.ID); err != nil {
t.Fatalf("DisableAPIToken() error = %v", err)
}
if err := state.RevokeAPIToken(issued.ID, now.Add(2*time.Hour)); err != nil {
t.Fatalf("RevokeAPIToken() error = %v", err)
}
tokens, err = state.APITokens()
if err != nil {
t.Fatalf("APITokens() after revoke error = %v", err)
}
if len(tokens) != 1 || !tokens[0].Disabled || tokens[0].RevokedAt == nil {
t.Fatalf("APITokens() after revoke = %#v, want disabled revoked token", tokens)
}
if err := state.DeleteAPIToken(issued.ID); err != nil {
t.Fatalf("DeleteAPIToken() error = %v", err)
}
tokens, err = state.APITokens()
if err != nil {
t.Fatalf("APITokens() after delete error = %v", err)
}
if len(tokens) != 0 {
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
}
}
@@ -48,14 +171,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "lights",
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
}
got, err := state.VisibleEntries()
@@ -63,11 +186,46 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
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"}},
},
},
},
CurrentPath: []string{"Crew"},
SearchQuery: "missing",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
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))
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel()
@@ -191,7 +349,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
@@ -260,13 +418,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Joe"},
CurrentPath: []string{"Crew"},
}
got, err := state.ChildGroups()
@@ -312,19 +470,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
CurrentPath: []string{"Crew", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
if err := state.ToggleVisibleIndex(1); err != nil {
@@ -366,14 +524,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.DeleteSelectedEntry(); err != nil {
@@ -388,8 +546,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
}
if !state.Dirty {
@@ -403,7 +561,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
@@ -412,7 +570,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("git-server"); err != nil {
if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -421,8 +579,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
}
if !state.Dirty {
@@ -442,19 +600,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
}
entry := vault.Entry{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
got, err := state.VisibleEntries()
@@ -462,7 +620,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
}
if !state.Dirty {
@@ -494,11 +652,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
})
if err != nil {
@@ -509,16 +667,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "dynadot" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
}
if !state.Dirty {
@@ -585,24 +743,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
}
if state.SelectedEntryID != "git-server-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -614,13 +772,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -633,8 +791,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
}
if !state.Dirty {
@@ -648,19 +806,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
ID: "vault-console",
Title: "Vault Console",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -704,7 +862,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -733,7 +891,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -784,7 +942,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -812,14 +970,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.Lock(); err != nil {
@@ -842,7 +1000,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
locked: true,
@@ -860,8 +1018,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
}
}
@@ -890,7 +1048,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{
Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
SearchQuery: "git",
}
@@ -926,7 +1084,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel()
state := State{
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
ErrorMessage: "previous error",
}
@@ -970,7 +1128,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
state.EnterGroup("Internet")
@@ -988,7 +1146,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1058,6 +1216,27 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
}
}
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{
Session: session,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Infrastructure / Prod"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
}
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -1118,7 +1297,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1134,14 +1313,45 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
}
}
func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
session := &mutableStubSession{model: model}
state := State{
Session: session,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); 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 got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -1149,7 +1359,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1177,7 +1387,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1203,7 +1413,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1229,7 +1439,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1247,7 +1457,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1273,7 +1483,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1301,8 +1511,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
},
}
}
@@ -1374,7 +1584,43 @@ 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
}
type stubApprovalManager struct {
pending []apiapproval.Request
lastID string
lastOutcome apiapproval.Outcome
}
func (s stubApprovalManager) Pending() []apiapproval.Request {
return append([]apiapproval.Request(nil), s.pending...)
}
func (s *stubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
s.lastID = id
s.lastOutcome = outcome
return apiapproval.Request{ID: id}, nil, nil
}
+23
View File
@@ -0,0 +1,23 @@
package assets
import (
"bytes"
"embed"
"image"
"image/png"
)
//go:embed *.png *.svg
var files embed.FS
func MustPNG(name string) image.Image {
data, err := files.ReadFile(name)
if err != nil {
panic(err)
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
panic(err)
}
return img
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
<title>KeePassGO icon</title>
<desc>Vault-shaped mark with lock, layered record lines, and navigation accent.</desc>
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<circle cx="128" cy="138" r="8" fill="#FFFFFF" opacity="0.2"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="260" viewBox="0 0 920 260" fill="none">
<title>KeePassGO horizontal logo</title>
<desc>KeePassGO symbol with wordmark for light-theme desktop UI.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
</defs>
<rect width="920" height="260" fill="transparent"/>
<use href="#kpg-icon" x="24" y="20" width="176" height="176"/>
<text x="220" y="132" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="84" font-weight="650" fill="#3F4E63">KeePassGO</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+75
View File
@@ -0,0 +1,75 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" fill="none">
<title>KeePassGO splash screen</title>
<desc>Light-theme desktop splash screen with structured panels and the KeePassGO logo.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#D9E0E6" stop-opacity="0.9"/>
<stop offset="1" stop-color="#D9E0E6" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect width="1280" height="800" fill="#F7F7F5"/>
<g opacity="0.9">
<rect x="48" y="48" width="238" height="188" fill="#EEF2F4"/>
<rect x="286" y="48" width="152" height="104" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="438" y="48" width="214" height="104" fill="#E7EDF1"/>
<rect x="652" y="48" width="146" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="48" width="224" height="118" fill="#EEF2F4"/>
<rect x="1022" y="48" width="210" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="48" y="236" width="128" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="176" y="236" width="262" height="164" fill="#E7EDF1"/>
<rect x="438" y="236" width="160" height="84" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="598" y="236" width="200" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="236" width="434" height="164" fill="#EEF2F4"/>
<rect x="48" y="400" width="224" height="128" fill="#EEF2F4"/>
<rect x="272" y="400" width="166" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="438" y="400" width="360" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="400" width="152" height="128" fill="#E7EDF1"/>
<rect x="950" y="400" width="282" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="48" y="528" width="114" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="162" y="528" width="276" height="224" fill="#E7EDF1"/>
<rect x="438" y="528" width="212" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="650" y="528" width="318" height="224" fill="#EEF2F4"/>
<rect x="968" y="528" width="264" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
</g>
<g opacity="0.45">
<path d="M48 152H1232" stroke="#C7D0D8"/>
<path d="M48 320H1232" stroke="#C7D0D8"/>
<path d="M48 528H1232" stroke="#C7D0D8"/>
<path d="M176 48V752" stroke="#C7D0D8"/>
<path d="M438 48V752" stroke="#C7D0D8"/>
<path d="M798 48V752" stroke="#C7D0D8"/>
<path d="M1022 48V752" stroke="#C7D0D8"/>
</g>
<rect x="290" y="190" width="700" height="420" rx="18" fill="#F7F7F5" opacity="0.92"/>
<rect x="290" y="190" width="700" height="420" rx="18" stroke="#C7D0D8"/>
<rect x="290" y="190" width="700" height="120" rx="18" fill="url(#fade)"/>
<use href="#kpg-icon" x="530" y="232" width="220" height="220"/>
<text x="640" y="530" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
<text x="640" y="582" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
<g opacity="0.9">
<rect x="516" y="634" width="248" height="10" rx="5" fill="#D9E0E6"/>
<rect x="516" y="634" width="132" height="10" rx="5" fill="#6D89A8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
<title>KeePassGO square splash preview</title>
<desc>Square crop of the KeePassGO splash system.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
</defs>
<rect width="1024" height="1024" fill="#F7F7F5"/>
<g opacity="0.9">
<rect x="32" y="32" width="180" height="184" fill="#EEF2F4"/>
<rect x="212" y="32" width="264" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="476" y="32" width="180" height="184" fill="#E7EDF1"/>
<rect x="656" y="32" width="336" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="656" y="172" width="336" height="180" fill="#EEF2F4"/>
<rect x="32" y="216" width="236" height="210" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="268" y="216" width="388" height="210" fill="#EEF2F4"/>
<rect x="32" y="426" width="180" height="268" fill="#E7EDF1"/>
<rect x="212" y="426" width="312" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="524" y="426" width="468" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="32" y="694" width="280" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="312" y="694" width="268" height="298" fill="#EEF2F4"/>
<rect x="580" y="694" width="412" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
</g>
<rect x="162" y="190" width="700" height="644" rx="24" fill="#F7F7F5" opacity="0.95"/>
<rect x="162" y="190" width="700" height="644" rx="24" stroke="#C7D0D8"/>
<use href="#kpg-icon" x="332" y="252" width="360" height="360"/>
<text x="512" y="680" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
<text x="512" y="740" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+296
View File
@@ -0,0 +1,296 @@
package autofillcache
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.julianfamily.org/keepassgo/vault"
)
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
Host string `json:"host"`
Targets []string `json:"targets,omitempty"`
Path []string `json:"path,omitempty"`
}
type File struct {
UpdatedAt string `json:"updatedAt"`
Entries []Entry `json:"entries"`
}
type MatchStatus string
const (
MatchStatusNone MatchStatus = ""
MatchStatusFound MatchStatus = "found"
MatchStatusAmbiguous MatchStatus = "ambiguous"
MatchStatusMissing MatchStatus = "missing"
)
type MatchResult struct {
Status MatchStatus `json:"status"`
Entry Entry `json:"entry,omitempty"`
}
func Match(cache File, webURL string) (Entry, bool) {
result := Resolve(cache, webURL)
return result.Entry, result.Status == MatchStatusFound
}
func Resolve(cache File, webURL string) MatchResult {
target := normalizeURL(webURL)
if target.host == "" {
return MatchResult{Status: MatchStatusMissing}
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entryMatchesHost(entry, target.host) {
exactHost = append(exactHost, entry)
continue
}
if entryMatchesParentHost(entry, target.host) {
parentHost = append(parentHost, entry)
}
}
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
return result
}
return chooseEntry(target, parentHost)
}
func Build(model vault.Model, now time.Time) File {
entries := make([]Entry, 0, len(model.Entries))
for _, item := range model.Entries {
targets := collectTargets(item)
host := normalizeHost(item.URL)
if host == "" {
for _, target := range targets {
host = normalizeHost(target)
if host != "" {
break
}
}
}
if host == "" {
continue
}
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
continue
}
entries = append(entries, Entry{
ID: item.ID,
Title: item.Title,
Username: item.Username,
Password: item.Password,
URL: item.URL,
Host: host,
Targets: targets,
Path: append([]string(nil), item.Path...),
})
}
return File{
UpdatedAt: now.UTC().Format(time.RFC3339),
Entries: entries,
}
}
func Write(path string, model vault.Model, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(Build(model, now), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func Clear(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeHost(raw string) string {
return normalizeURL(raw).host
}
type normalizedTarget struct {
host string
path string
url string
}
func normalizeURL(raw string) normalizedTarget {
value := strings.TrimSpace(raw)
if value == "" {
return normalizedTarget{}
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return normalizedTarget{}
}
host := strings.TrimSpace(parsed.Hostname())
path := cleanPath(parsed.EscapedPath())
return normalizedTarget{
host: strings.ToLower(host),
path: path,
url: strings.ToLower(host) + path,
}
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
path = strings.TrimRight(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
switch len(entries) {
case 0:
return MatchResult{Status: MatchStatusMissing}
case 1:
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
}
exact := make([]Entry, 0)
bestPrefixLen := -1
bestPrefix := make([]Entry, 0)
for _, entry := range entries {
exactMatch, prefixLen := bestTargetMatch(entry, target)
if exactMatch {
exact = append(exact, entry)
continue
}
if prefixLen <= 0 {
continue
}
switch {
case prefixLen > bestPrefixLen:
bestPrefixLen = prefixLen
bestPrefix = []Entry{entry}
case prefixLen == bestPrefixLen:
bestPrefix = append(bestPrefix, entry)
}
}
if len(exact) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
}
if len(exact) > 1 {
return MatchResult{Status: MatchStatusAmbiguous}
}
if len(bestPrefix) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
}
if len(bestPrefix) == 0 {
return MatchResult{Status: MatchStatusMissing}
}
return MatchResult{Status: MatchStatusAmbiguous}
}
func collectTargets(item vault.Entry) []string {
seen := make(map[string]struct{})
targets := make([]string, 0, 1+len(item.Fields))
appendTarget := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
targets = append(targets, value)
}
appendTarget(item.URL)
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
upper := strings.ToUpper(strings.TrimSpace(key))
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
appendTarget(item.Fields[key])
}
}
return targets
}
func entryTargets(entry Entry) []normalizedTarget {
values := entry.Targets
if len(values) == 0 {
values = []string{entry.URL}
}
targets := make([]normalizedTarget, 0, len(values))
for _, value := range values {
target := normalizeURL(value)
if target.host == "" {
continue
}
targets = append(targets, target)
}
return targets
}
func entryMatchesHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host == host {
return true
}
}
return false
}
func entryMatchesParentHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
return true
}
}
return false
}
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
bestPrefixLen := -1
for _, candidate := range entryTargets(entry) {
if candidate.url == target.url {
return true, 0
}
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
bestPrefixLen = pathLen
}
}
}
return false, bestPrefixLen
}
+339
View File
@@ -0,0 +1,339 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.julianfamily.org/keepassgo/vault"
)
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
got := Build(vault.Model{
Entries: []vault.Entry{
{
ID: "one",
Title: "Chrome Test",
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"},
},
{
ID: "two",
Title: "No Password",
Username: "joe",
URL: "https://example.com",
},
{
ID: "three",
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
},
},
},
}, now)
if len(got.Entries) != 2 {
t.Fatalf("entry count = %d, want 2", len(got.Entries))
}
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 len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
}
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
t.Fatalf("updatedAt = %q", got.UpdatedAt)
}
}
func TestWriteAndClear(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "autofill-cache.json")
model := vault.Model{
Entries: []vault.Entry{
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
},
}
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var got File
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("cache entries = %#v", got.Entries)
}
if err := Clear(path); err != nil {
t.Fatalf("Clear() error = %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cache path still exists, stat err = %v", err)
}
}
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Primary Login",
Username: "first",
Password: "secret1",
URL: "https://10.0.2.2:8443/login/",
Host: "10.0.2.2",
},
{
ID: "two",
Title: "Alt Login",
Username: "second",
Password: "secret2",
URL: "https://10.0.2.2:8443/alt/",
Host: "10.0.2.2",
},
},
}
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Host A",
Username: "first",
Password: "secret1",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
},
}
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
}
}
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Admin Login",
Username: "admin",
Password: "secret1",
URL: "https://example.com/admin",
Host: "example.com",
},
{
ID: "two",
Title: "Shared Login A",
Username: "shared-a",
Password: "secret2",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
{
ID: "three",
Title: "Shared Login B",
Username: "shared-b",
Password: "secret3",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
},
}
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
}
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
}
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
t.Fatalf("Resolve(missing) = %#v, want missing", got)
}
}
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Generic Login",
Username: "generic",
Password: "secret1",
URL: "https://example.com/",
Host: "example.com",
},
{
ID: "two",
Title: "Admin Login",
Username: "admin",
Password: "secret2",
URL: "https://example.com/admin",
Host: "example.com",
},
},
}
got, ok := Match(cache, "https://example.com/admin/login")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird/login",
Host: "org.mozilla.thunderbird",
},
},
}
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird Primary",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
{
ID: "two",
Title: "Thunderbird Secondary",
Username: "other-user",
Password: "secret2",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
},
}
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
}
}
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://account.blinknetwork.com",
Host: "account.blinknetwork.com",
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
},
},
}
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://blinknetwork.com",
Host: "blinknetwork.com",
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
},
},
}
got, ok := Match(cache, "https://account.blinknetwork.com")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
+102
View File
@@ -0,0 +1,102 @@
package buildapk
import (
"fmt"
"os"
"path/filepath"
)
const (
DefaultSDKRoot = "/opt/android-sdk"
DefaultNDKRoot = "/opt/android-ndk"
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "assets/keepassgo-icon.png"
)
type Config struct {
SDKRoot string
NDKRoot string
JavaHome string
AppID string
APKOut string
Version string
MinSDK string
TargetSDK string
IconPath string
}
func DefaultConfig() Config {
return Config{
SDKRoot: DefaultSDKRoot,
NDKRoot: DefaultNDKRoot,
JavaHome: DefaultJavaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
}
}
func (c Config) GogioArgs() []string {
return []string{
"-target", "android",
"-buildmode", "exe",
"-appid", c.AppID,
"-o", c.APKOut,
"-version", c.Version,
"-minsdk", c.MinSDK,
"-targetsdk", c.TargetSDK,
"-icon", c.IconPath,
".",
}
}
func (c Config) Validate() error {
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
}
if !isDir(c.SDKRoot) {
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
}
if !isDir(c.NDKRoot) {
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
}
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
return fmt.Errorf("Android SDK cmdline-tools are missing")
}
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
}
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
return fmt.Errorf("Android build-tools are missing")
}
if !isFile(c.IconPath) {
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
}
return nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return info.Mode()&0o111 != 0
}
func isFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
+101
View File
@@ -0,0 +1,101 @@
package buildapk
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestDefaultConfigGogioArgs(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
want := []string{
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath,
".",
}
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
t.Fatalf("GogioArgs() = %v, want %v", got, want)
}
}
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
t.Parallel()
root := t.TempDir()
sdkRoot := filepath.Join(root, "sdk")
ndkRoot := filepath.Join(root, "ndk")
javaHome := filepath.Join(root, "java")
mkdirAll(t, filepath.Join(javaHome, "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
mkdirAll(t, ndkRoot)
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
cfg := Config{
SDKRoot: sdkRoot,
NDKRoot: ndkRoot,
JavaHome: javaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
}
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
}
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
t.Parallel()
root := t.TempDir()
cfg := Config{
SDKRoot: filepath.Join(root, "missing-sdk"),
NDKRoot: filepath.Join(root, "missing-ndk"),
JavaHome: filepath.Join(root, "missing-java"),
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want missing prerequisite error")
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
}
func makeExecutable(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
+4
View File
@@ -52,6 +52,10 @@ func (s Service) writer() Writer {
return systemWriter{}
}
func WriteText(text string) error {
return systemWriter{}.WriteText(text)
}
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == entryID {
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
},
},
}
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target
want string
}{
{name: "username", target: TargetUsername, want: "joejulian"},
{name: "username", target: TargetUsername, want: "dannyocean"},
{name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
}
for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter
service := Service{Writer: &writer}
if err := service.Copy(model, "git-server", tt.target); err != nil {
if err := service.Copy(model, "vault-console", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err)
}
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
model := vault.Model{
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
}
err = service.Copy(model, "git-server", Target("unsupported"))
err = service.Copy(model, "vault-console", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
}
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Password: "token-1"},
{ID: "vault-console", Password: "token-1"},
},
}
err := service.Copy(model, "git-server", TargetPassword)
err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/clipboard"
)
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
+48
View File
@@ -0,0 +1,48 @@
# Accessibility Review
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
## What is intentionally supported
- Keyboard focus is explicit for:
- vault search
- breadcrumb navigation
- entry list selection
- detail/editor fields
- Focus styling is visible and distinct from the unfocused field treatment.
- Common keyboard workflows are covered in-repo by tests for:
- tab navigation
- 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).
## Current screen-reader boundary
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
- KeePassGO therefore treats screen-reader support as:
- label-conscious where the toolkit exposes focusable controls
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
## Current review result
- Linux:
- keyboard/focus behavior is intentionally supported
- visible focus states and control naming are present in code
- full Orca-style semantic verification is not something this repo can assert automatically today
- Windows:
- the same keyboard/focus behavior and explicit labels are present in-app
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
## What KeePassGO should continue doing
- Keep every major workflow operable without a pointer device.
- Add explicit labels for any new focusable control.
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
- Prefer dialogs and panels that keep keyboard focus predictable.
## What remains toolkit-limited
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
+6 -3
View File
@@ -10,6 +10,9 @@ KeePassGO supports the following KDBX security workflows today:
- preserve the original opened vault's KDBX format version during save
- preserve the original opened vault's cipher selection during save
- preserve the original opened vault's KDF selection during save
- choose the cipher family for new vault creation
- choose the KDF family for new vault creation
- change the cipher family and KDF family for an existing unlocked session before the next save
What "preserve" means:
@@ -18,11 +21,11 @@ What "preserve" means:
Current explicit limitations:
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
- new vault creation still uses the library default KDBX header settings for freshly created databases
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
Practical expectation:
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
- KeePassGO does not yet try to be a full advanced database-settings editor
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
+8 -2
View File
@@ -2,8 +2,11 @@ module git.julianfamily.org/keepassgo
go 1.26
replace gioui.org/cmd => ./third_party/gioui-cmd
require (
gioui.org v0.9.0
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
@@ -14,8 +17,9 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.9.0 // 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
@@ -72,6 +76,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -190,6 +195,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
+22 -4
View File
@@ -37,13 +37,15 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20=
gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
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=
@@ -128,6 +130,10 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -216,6 +222,14 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
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=
@@ -354,6 +368,8 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -386,6 +402,8 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
+3643 -430
View File
File diff suppressed because it is too large Load Diff
+3021 -228
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
pkgbase = keepassgo-git
pkgdesc = KeePass-compatible password manager written in Go
pkgver = r160.5fa79bd
pkgrel = 1
url = https://git.julianfamily.org/joejulian/keepassgo
arch = x86_64
arch = aarch64
license = custom
makedepends = git
makedepends = go
makedepends = pkgconf
depends = glibc
depends = hicolor-icon-theme
depends = libx11
depends = libxcursor
depends = libxfixes
depends = libxkbcommon
depends = libxkbcommon-x11
depends = mesa
depends = wayland
provides = keepassgo
conflicts = keepassgo
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
sha256sums = SKIP
pkgname = keepassgo-git
@@ -0,0 +1,63 @@
pkgname=keepassgo-git
pkgver=r0.0000000
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
printf '%s\n' "${srcdir}/keepassgo"
return
fi
cd "${startdir}/../../.." || exit 1
pwd
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
go build -o keepassgo .
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=KeePassGO
Comment=KeePass-compatible password manager
Exec=keepassgo
Icon=keepassgo
Terminal=false
Categories=Utility;Security;
Keywords=keepass;password;vault;kdbx;
StartupNotify=true
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import argparse
import json
import mimetypes
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict:
data = None
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def request_no_content(method: str, url: str, token: str) -> None:
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
method=method,
)
with urllib.request.urlopen(req):
return
def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}"
try:
return request_json("GET", tag_url, token)
except urllib.error.HTTPError as err:
if err.code != 404:
raise
payload = {
"tag_name": tag,
"name": tag,
"draft": False,
"prerelease": False,
}
return request_json("POST", f"{base}/releases", token, payload)
def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
assets = release.get("assets", [])
for asset in assets:
if asset.get("name") == path.name:
request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token)
query = urllib.parse.urlencode({"name": path.name})
url = f"{base}/releases/{release['id']}/assets?{query}"
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
req = urllib.request.Request(
url,
data=path.read_bytes(),
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": content_type,
},
method="POST",
)
with urllib.request.urlopen(req):
return
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("artifacts", nargs="+")
args = parser.parse_args()
paths = [pathlib.Path(p) for p in args.artifacts]
missing = [str(p) for p in paths if not p.is_file()]
if missing:
print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr)
return 1
release = get_or_create_release(args.server, args.repo, args.token, args.tag)
for path in paths:
upload_asset(args.server, args.repo, args.token, release, path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+164 -40
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
@@ -32,6 +33,46 @@ type Manager struct {
remoteVersion webdav.Version
}
type PreparedLocalOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
}
type PreparedRemoteOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Client webdav.Client
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
}
type PreparedUnlock struct {
Model vault.Model
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings {
return vault.DetectSecuritySettings(m.config)
}
func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
config, err := vault.ApplySecuritySettings(configOrCurrent(m.config, nil), settings)
if err != nil {
return fmt.Errorf("configure security settings: %w", err)
}
m.config = config
return nil
}
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model)
model = normalizeUnderRoot(model, root)
@@ -52,6 +93,10 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
func (m *Manager) IsLocked() bool {
return m.locked
}
@@ -61,23 +106,11 @@ func (m *Manager) IsRemote() bool {
}
func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path)
prepared, err := PrepareLocalOpen(path, key)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
return err
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
m.model = model
m.config = config
m.path = path
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.ApplyPreparedLocalOpen(prepared)
return nil
}
@@ -94,25 +127,11 @@ func (m *Manager) Save() error {
}
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
content, version, err := client.Open(path)
prepared, err := PrepareRemoteOpen(client, path, key)
if err != nil {
return fmt.Errorf("open remote %s: %w", path, err)
return err
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("decode remote %s: %w", path, err)
}
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.remoteClient = &client
m.remotePath = path
m.remoteVersion = version
m.ApplyPreparedRemoteOpen(prepared)
return nil
}
@@ -159,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil {
@@ -252,19 +283,101 @@ func (m *Manager) Lock() error {
}
func (m *Manager) Unlock(key vault.MasterKey) error {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
prepared, err := PrepareUnlock(m.encoded, key)
if err != nil {
return fmt.Errorf("unlock vault: %w", err)
return err
}
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.locked = false
m.ApplyPreparedUnlock(prepared)
return nil
}
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
content, err := os.ReadFile(path)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
content, version, err := client.Open(path)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
RemoteVersion: version,
}, nil
}
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
if err != nil {
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.path = prepared.Path
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = &prepared.Client
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
var (
model vault.Model
@@ -808,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil
}
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key)
if err == nil {
+169 -83
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
}
}
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Joe", "Internet"},
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Crew", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "joejulian",
Username: "dannyocean",
Password: "token-2",
URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Joe", "eMail"},
URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Crew", "eMail"},
},
},
}, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current)
if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
}
}
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
}
}
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-1",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
}
var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
},
},
},
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID,
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path,
})
sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -741,6 +741,35 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
}
}
func TestConfigureSecurityAppliesToCreatedVaultAndPersists(t *testing.T) {
t.Parallel()
var sess Manager
if err := sess.ConfigureSecurity(vault.SecuritySettings{
Cipher: vault.CipherAES256,
KDF: vault.KDFAES,
}); err != nil {
t.Fatalf("ConfigureSecurity() error = %v", err)
}
if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
path := filepath.Join(t.TempDir(), "secure.kdbx")
if err := sess.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
var reopened Manager
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Open() error = %v", err)
}
got := reopened.SecuritySettings()
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
}
}
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
t.Parallel()
@@ -749,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -812,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "remote-token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
@@ -830,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens",
URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
@@ -854,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
}
if len(got[0].History) == 0 {
@@ -876,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -888,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -924,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
}
}
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
@@ -935,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -947,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -992,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -1004,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-remote",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
+67
View File
@@ -0,0 +1,67 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing
packages:
- clang
- cmake
- curl
- autoconf
- libxml2-dev
- libssl-dev
- libz-dev
- llvm-dev # for cctools
- uuid-dev ## for cctools
- libplist-utils # for gogio
sources:
- https://git.sr.ht/~eliasnaur/gio-cmd
- https://git.sr.ht/~eliasnaur/applesdks
- https://git.sr.ht/~eliasnaur/giouiorg
- https://github.com/tpoechtrager/cctools-port.git
- https://github.com/tpoechtrager/apple-libtapi.git
- https://github.com/mackyle/xar.git
environment:
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- prepare_toolchain: |
mkdir -p $APPLE_TOOLCHAIN_ROOT
cd $APPLE_TOOLCHAIN_ROOT
tar xJf /home/build/applesdks/applesdks.tar.xz
mkdir bin tools
cd bin
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
ln -s ../tools/appletoolchain xcrun
ln -s /usr/bin/plistutil plutil
cd ../tools
ln -s appletoolchain clang-ios
ln -s appletoolchain clang-macos
- install_appletoolchain: |
cd giouiorg
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
- build_xar: |
cd xar/xar
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
make
sudo make install
- build_libtapi: |
cd apple-libtapi
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
./install.sh
- build_cctools: |
cd cctools-port/cctools
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
make install
- install_gogio: |
cd gio-cmd
go install ./gogio
- test_ios_gogio: |
mkdir tmp
cd tmp
go mod init example.com
go get -d gioui.org/example/kitchen
export PATH=/home/build/appletools/bin:$PATH
gogio -target ios -o app.app gioui.org/example/kitchen
+22
View File
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: freebsd/13.x
packages:
- libX11
- libxkbcommon
- libXcursor
- libXfixes
- vulkan-headers
- wayland
- mesa-libs
- xorg-vfbserver
sources:
- https://git.sr.ht/~eliasnaur/gio-cmd
environment:
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- test_cmd: |
cd gio-cmd
go test ./...
+91
View File
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: debian/bookworm
packages:
- curl
- pkg-config
- libwayland-dev
- libx11-dev
- libx11-xcb-dev
- libxkbcommon-dev
- libxkbcommon-x11-dev
- libgles2-mesa-dev
- libegl1-mesa-dev
- libffi-dev
- libvulkan-dev
- libxcursor-dev
- libxrandr-dev
- libxinerama-dev
- libxi-dev
- libxxf86vm-dev
- mesa-vulkan-drivers
- wine
- xvfb
- xdotool
- scrot
- sway
- grim
- wine
- unzip
sources:
- https://git.sr.ht/~eliasnaur/gio-cmd
environment:
PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
ANDROID_SDK_ROOT: /home/build/android
android_sdk_tools_zip: sdk-tools-linux-3859397.zip
android_ndk_zip: android-ndk-r20-linux-x86_64.zip
github_mirror: git@github.com:gioui/gio-cmd
secrets:
- fdc570bf-87f4-4528-8aee-4d1711b1c86f
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- check_gofmt: |
cd gio-cmd
test -z "$(gofmt -s -l .)"
- check_sign_off: |
set +x -e
cd gio-cmd
for hash in $(git log -n 20 --format="%H"); do
message=$(git log -1 --format=%B $hash)
if [[ ! "$message" =~ "Signed-off-by: " ]]; then
echo "Missing 'Signed-off-by' in commit $hash"
exit 1
fi
done
- mirror: |
# mirror to github
ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
- install_chrome: |
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
sudo apt-get -qq update
sudo apt-get -qq install -y google-chrome-stable
- test: |
cd gio-cmd
go test ./...
go test -race ./...
- install_jdk8: |
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
sudo apt-get -qq install -y -f ./jdk.deb
- install_android: |
mkdir android
cd android
curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
unzip -q sdk-tools.zip
rm sdk-tools.zip
curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
unzip -q ndk.zip
rm ndk.zip
mv android-ndk-* ndk-bundle
yes|sdkmanager --licenses
sdkmanager "platforms;android-31" "build-tools;32.0.0"
- install_gogio: |
cd gio-cmd
go install ./gogio
- test_android_gogio: |
mkdir tmp
cd tmp
go mod init example.com
go get -d gioui.org/example/kitchen
gogio -target android gioui.org/example/kitchen
+18
View File
@@ -0,0 +1,18 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: openbsd/latest
packages:
- libxkbcommon
- go
sources:
- https://git.sr.ht/~eliasnaur/gio-cmd
environment:
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
cd /home/build/sdk/go/src
./make.bash
- test_cmd: |
cd gio-cmd
go test ./...
+63
View File
@@ -0,0 +1,63 @@
This project is provided under the terms of the UNLICENSE or
the MIT license denoted by the following SPDX identifier:
SPDX-License-Identifier: Unlicense OR MIT
You may use the project under the terms of either license.
Both licenses are reproduced below.
----
The MIT License (MIT)
Copyright (c) 2019 The Gio authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
---
The UNLICENSE
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
---
+21
View File
@@ -0,0 +1,21 @@
# Gio Tools
Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
[![builds.sr.ht status](https://builds.sr.ht/~eliasnaur/gio-cmd.svg)](https://builds.sr.ht/~eliasnaur/gio-cmd)
## Issues
File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).
## Contributing
Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
account is required and you can post without being subscribed.
See the [contribution guide](https://gioui.org/doc/contribute) for more details.
An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.
+28
View File
@@ -0,0 +1,28 @@
module gioui.org/cmd
go 1.21
require (
gioui.org v0.8.0
github.com/akavel/rsrc v0.10.1
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
github.com/chromedp/chromedp v0.5.2
golang.org/x/image v0.18.0
golang.org/x/sync v0.7.0
golang.org/x/text v0.16.0
golang.org/x/tools v0.23.0
)
require (
gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
github.com/gobwas/pool v0.2.0 // indirect
github.com/gobwas/ws v1.0.2 // indirect
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
github.com/mailru/easyjson v0.7.0 // indirect
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/sys v0.22.0 // indirect
)
+44
View File
@@ -0,0 +1,44 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/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=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
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/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"os"
"os/exec"
"path/filepath"
"regexp"
)
type AndroidTestDriver struct {
driverBase
sdkDir string
adbPath string
}
var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)
func (d *AndroidTestDriver) Start(path string) {
d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
if d.sdkDir == "" {
d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
}
d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
d.Skipf("adb not found")
}
devOut := bytes.TrimSpace(d.adb("devices"))
devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
switch len(devices) {
case 0:
d.Skipf("no Android devices attached via adb; skipping")
case 1:
default:
d.Skipf("multiple Android devices attached via adb; skipping")
}
// If the device is attached but asleep, it's probably just charging.
// Don't use it; the screen needs to be on and unlocked for the test to
// work.
if !bytes.Contains(
d.adb("shell", "dumpsys", "power"),
[]byte(" mWakefulness=Awake"),
) {
d.Skipf("Android device isn't awake; skipping")
}
// First, build the app.
apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)
// Make sure the app isn't installed already, and try to uninstall it
// when we finish. Previous failed test runs might have left the app.
d.tryUninstall()
d.adb("install", apk)
d.Cleanup(d.tryUninstall)
// Force our e2e app to be fullscreen, so that the android system bar at
// the top doesn't mess with our screenshots.
// TODO(mvdan): is there a way to do this via gio, so that we don't need
// to set up a global Android setting via the shell?
d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)
// Make sure the app isn't already running.
d.adb("shell", "pm", "clear", appid)
// Start listening for log messages.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, d.adbPath,
"logcat",
"-s", // suppress other logs
"-T1", // don't show previous log messages
appid+":*", // show all logs from our gio app ID
)
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
}
// Start the app.
d.adb("shell", "monkey", "-p", appid, "1")
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *AndroidTestDriver) Screenshot() image.Image {
out := d.adb("shell", "screencap", "-p")
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *AndroidTestDriver) tryUninstall() {
cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
out, err := cmd.CombinedOutput()
if err != nil {
if bytes.Contains(out, []byte("Unknown package")) {
// The package is not installed. Don't log anything.
return
}
d.Logf("could not uninstall: %v\n%s", err, out)
}
}
func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
strs := []string{}
for _, arg := range args {
strs = append(strs, fmt.Sprint(arg))
}
cmd := exec.Command(d.adbPath, strs...)
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
return out
}
func (d *AndroidTestDriver) Click(x, y int) {
d.adb("shell", "input", "tap", x, y)
// Wait for the gio app to render after this click.
d.waitForFrame()
}
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"unicode"
"unicode/utf8"
)
type buildInfo struct {
appID string
archs []string
ldflags string
minsdk int
targetsdk int
name string
pkgDir string
pkgPath string
iconPath string
tags string
target string
version Semver
key string
password string
notaryAppleID string
notaryPassword string
notaryTeamID string
}
type Semver struct {
Major, Minor, Patch int
VersionCode uint32
}
func newBuildInfo(pkgPath string) (*buildInfo, error) {
pkgMetadata, err := getPkgMetadata(pkgPath)
if err != nil {
return nil, err
}
appID := getAppID(pkgMetadata)
appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
if *iconPath != "" {
appIcon = *iconPath
}
appName := getPkgName(pkgMetadata)
if *name != "" {
appName = *name
}
ver, err := parseSemver(*version)
if err != nil {
return nil, err
}
bi := &buildInfo{
appID: appID,
archs: getArchs(),
ldflags: getLdFlags(appID),
minsdk: *minsdk,
targetsdk: *targetsdk,
name: appName,
pkgDir: pkgMetadata.Dir,
pkgPath: pkgPath,
iconPath: appIcon,
tags: *extraTags,
target: *target,
version: ver,
key: *signKey,
password: *signPass,
notaryAppleID: *notaryID,
notaryPassword: *notaryPass,
notaryTeamID: *notaryTeamID,
}
return bi, nil
}
// UppercaseName returns a string with its first rune in uppercase.
func UppercaseName(name string) string {
ch, w := utf8.DecodeRuneInString(name)
return string(unicode.ToUpper(ch)) + name[w:]
}
func (s Semver) String() string {
return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode)
}
func parseSemver(v string) (Semver, error) {
var sv Semver
_, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode)
if err != nil {
return Semver{}, fmt.Errorf("invalid semver: %q", v)
}
if sv.String() != v {
return Semver{}, fmt.Errorf("invalid semver: %q", v)
}
return sv, nil
}
func getArchs() []string {
if *archNames != "" {
return strings.Split(*archNames, ",")
}
switch *target {
case "js":
return []string{"wasm"}
case "ios", "tvos":
// Only 64-bit support.
return []string{"arm64", "amd64"}
case "android":
return []string{"arm", "arm64", "386", "amd64"}
case "windows":
goarch := os.Getenv("GOARCH")
if goarch == "" {
goarch = runtime.GOARCH
}
return []string{goarch}
case "macos":
return []string{"arm64", "amd64"}
default:
// TODO: Add flag tests.
panic("The target value has already been validated, this will never execute.")
}
}
func getLdFlags(appID string) string {
var ldflags []string
if extra := *extraLdflags; extra != "" {
ldflags = append(ldflags, strings.Split(extra, " ")...)
}
// Pass appID along, to be used for logging on platforms like Android.
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID))
// Support earlier Gio versions that had a separate app id recorded.
// TODO: delete this in the future.
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
// Pass along all remaining arguments to the app.
if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
}
if m := *linkMode; m != "" {
ldflags = append(ldflags, "-linkmode="+m)
}
return strings.Join(ldflags, " ")
}
type packageMetadata struct {
PkgPath string
Dir string
}
func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath))
if err != nil {
return nil, err
}
pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath))
if err != nil {
return nil, err
}
return &packageMetadata{
PkgPath: pkgImportPath,
Dir: pkgDir,
}, nil
}
func getAppID(pkgMetadata *packageMetadata) string {
if *appID != "" {
return *appID
}
elems := strings.Split(pkgMetadata.PkgPath, "/")
domain := strings.Split(elems[0], ".")
name := ""
if len(elems) > 1 {
name = "." + elems[len(elems)-1]
}
if len(elems) < 2 && len(domain) < 2 {
name = "." + domain[0]
domain[0] = "localhost"
} else {
for i := 0; i < len(domain)/2; i++ {
opp := len(domain) - 1 - i
domain[i], domain[opp] = domain[opp], domain[i]
}
}
pkgDomain := strings.Join(domain, ".")
appid := []rune(pkgDomain + name)
// a Java-language-style package name may contain upper- and lower-case
// letters and underscores with individual parts separated by '.'.
// https://developer.android.com/guide/topics/manifest/manifest-element
for i, c := range appid {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
c == '_' || c == '.') {
appid[i] = '_'
}
}
return string(appid)
}
func getPkgName(pkgMetadata *packageMetadata) string {
return path.Base(pkgMetadata.PkgPath)
}
+32
View File
@@ -0,0 +1,32 @@
package main
import "testing"
type expval struct {
in, out string
}
func TestAppID(t *testing.T) {
t.Parallel()
tests := []expval{
{"example", "localhost.example"},
{"example.com", "com.example"},
{"www.example.com", "com.example.www"},
{"examplecom/app", "examplecom.app"},
{"example.com/app", "com.example.app"},
{"www.example.com/app", "com.example.www.app"},
{"www.en.example.com/app", "com.example.en.www.app"},
{"example.com/dir/app", "com.example.app"},
{"example.com/dir.ext/app", "com.example.app"},
{"example.com/dir/app.ext", "com.example.app.ext"},
{"example-com.net/dir/app", "net.example_com.app"},
}
for i, test := range tests {
got := getAppID(&packageMetadata{PkgPath: test.in})
if exp := test.out; got != exp {
t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: Unlicense OR MIT
/*
The gogio tool builds and packages Gio programs for Android, iOS/tvOS
and WebAssembly.
Run gogio with no arguments for instructions, or see the examples at
https://gioui.org.
*/
package main
+337
View File
@@ -0,0 +1,337 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bufio"
"errors"
"flag"
"fmt"
"image"
"image/color"
"io"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
)
var raceEnabled = false
var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")
const appid = "localhost.gogio.endtoend"
// TestDriver is implemented by each of the platforms we can run end-to-end
// tests on. None of its methods return any errors, as the errors are directly
// reported to testing.T via methods like Fatal.
type TestDriver interface {
initBase(t *testing.T, width, height int)
// Start opens the Gio app found at path. The driver should attempt to
// run the app with the base driver's width and height, and the
// platform's background should be white.
//
// When the function returns, the gio app must be ready to use on the
// platform, with its initial frame fully drawn.
Start(path string)
// Screenshot takes a screenshot of the Gio app on the platform.
Screenshot() image.Image
// Click performs a pointer click at the specified coordinates,
// including both press and release. It returns when the next frame is
// fully drawn.
Click(x, y int)
}
type driverBase struct {
*testing.T
width, height int
output io.Reader
frameNotifs chan bool
}
func (d *driverBase) initBase(t *testing.T, width, height int) {
d.T = t
d.width, d.height = width, height
}
func TestEndToEnd(t *testing.T) {
if testing.Short() {
t.Skipf("end-to-end tests tend to be slow")
}
t.Parallel()
const (
testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal"
testdataWithRelativePkgPath = "internal/normal/testdata.go"
customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go"
)
// Keep this list local, to not reuse TestDriver objects.
subtests := []struct {
name string
driver TestDriver
pkgPath string
skipGeese string
}{
{"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""},
{"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""},
{"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"},
// Doesn't work on the builders.
//{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
{"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""},
{"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""},
{"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""},
}
for _, subtest := range subtests {
t.Run(subtest.name, func(t *testing.T) {
subtest := subtest // copy the changing loop variable
if strings.Contains(subtest.skipGeese, runtime.GOOS) {
t.Skipf("not supported on %s", runtime.GOOS)
}
t.Parallel()
runEndToEndTest(t, subtest.driver, subtest.pkgPath)
})
}
}
func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
size := image.Point{X: 800, Y: 600}
driver.initBase(t, size.X, size.Y)
t.Log("starting driver and gio app")
driver.Start(pkgPath)
beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
// These are the four colors at the beginning.
t.Log("taking initial screenshot")
withRetries(t, 4*time.Second, func() error {
img := driver.Screenshot()
size = img.Bounds().Size() // override the default size
return checkImageCorners(img, beef, white, black, gray)
})
// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
// almost works to automate clicks, but the button presses end up in the
// wrong coordinates.
if _, ok := driver.(*WaylandTestDriver); ok {
return
}
// Click the first and last sections to turn them red.
t.Log("clicking twice and taking another screenshot")
driver.Click(1*(size.X/4), 1*(size.Y/4))
driver.Click(3*(size.X/4), 3*(size.Y/4))
withRetries(t, 4*time.Second, func() error {
img := driver.Screenshot()
return checkImageCorners(img, red, white, black, red)
})
}
// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
// such, timeout should generally be in the order of seconds.
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
t.Helper()
timeoutTimer := time.NewTimer(timeout)
defer timeoutTimer.Stop()
backoff := 100 * time.Millisecond
tries := 0
var lastErr error
for {
if lastErr = fn(); lastErr == nil {
return
}
tries++
t.Logf("retrying after %s", backoff)
// Use a timer instead of a sleep, so that the timeout can stop
// the backoff early. Don't reuse this timer, since we're not in
// a hot loop, and we don't want tricky code.
backoffTimer := time.NewTimer(backoff)
defer backoffTimer.Stop()
select {
case <-timeoutTimer.C:
t.Errorf("last error: %v", lastErr)
t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
case <-backoffTimer.C:
}
// Keep doubling it until a maximum. With the start at 100ms,
// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
backoff *= 2
if max := 2 * time.Second; backoff > max {
backoff = max
}
}
}
type colorMismatch struct {
x, y int
wantRGB, gotRGB [3]uint32
}
func (m colorMismatch) String() string {
return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
m.x, m.y,
m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
)
}
func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
// The colors are split in four rectangular sections. Check the corners
// of each of the sections. We check the corners left to right, top to
// bottom, like when reading left-to-right text.
size := img.Bounds().Size()
var mismatches []colorMismatch
checkColor := func(x, y int, want color.Color) {
r, g, b, _ := want.RGBA()
got := img.At(x, y)
r_, g_, b_, _ := got.RGBA()
if r_ != r || g_ != g || b_ != b {
mismatches = append(mismatches, colorMismatch{
x: x,
y: y,
wantRGB: [3]uint32{r, g, b},
gotRGB: [3]uint32{r_, g_, b_},
})
}
}
{
minX, minY := 5, 5
maxX, maxY := (size.X/2)-5, (size.Y/2)-5
checkColor(minX, minY, topLeft)
checkColor(maxX, minY, topLeft)
checkColor(minX, maxY, topLeft)
checkColor(maxX, maxY, topLeft)
}
{
minX, minY := (size.X/2)+5, 5
maxX, maxY := size.X-5, (size.Y/2)-5
checkColor(minX, minY, topRight)
checkColor(maxX, minY, topRight)
checkColor(minX, maxY, topRight)
checkColor(maxX, maxY, topRight)
}
{
minX, minY := 5, (size.Y/2)+5
maxX, maxY := (size.X/2)-5, size.Y-5
checkColor(minX, minY, botLeft)
checkColor(maxX, minY, botLeft)
checkColor(minX, maxY, botLeft)
checkColor(maxX, maxY, botLeft)
}
{
minX, minY := (size.X/2)+5, (size.Y/2)+5
maxX, maxY := size.X-5, size.Y-5
checkColor(minX, minY, botRight)
checkColor(maxX, minY, botRight)
checkColor(minX, maxY, botRight)
checkColor(maxX, maxY, botRight)
}
if n := len(mismatches); n > 0 {
b := new(strings.Builder)
fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
for _, m := range mismatches {
fmt.Fprintf(b, "%s\n", m)
}
return errors.New(b.String())
}
return nil
}
func (d *driverBase) waitForFrame() {
d.Helper()
if d.frameNotifs == nil {
// Start the goroutine that reads output lines and notifies of
// new frames via frameNotifs. The test doesn't wait for this
// goroutine to finish; it will naturally end when the output
// reader reaches an error like EOF.
d.frameNotifs = make(chan bool, 1)
if d.output == nil {
d.Fatal("need an output reader to be notified of frames")
}
go func() {
scanner := bufio.NewScanner(d.output)
for scanner.Scan() {
line := scanner.Text()
d.Log(line)
if strings.Contains(line, "gio frame ready") {
d.frameNotifs <- true
}
}
// Since we're only interested in the output while the
// app runs, and we don't know when it finishes here,
// ignore "already closed" pipe errors.
if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
d.Errorf("reading app output: %v", err)
}
}()
}
// Unfortunately, there isn't a way to select on a test failing, since
// testing.T doesn't have anything like a context or a "done" channel.
//
// We can't let selects block forever, since the default -test.timeout
// is ten minutes - far too long for tests that take seconds.
//
// For now, a static short timeout is better than nothing. 5s is plenty
// for our simple test app to render on any device.
select {
case <-d.frameNotifs:
case <-time.After(5 * time.Second):
d.Fatalf("timed out waiting for a frame to be ready")
}
}
func (d *driverBase) needPrograms(names ...string) {
d.Helper()
for _, name := range names {
if _, err := exec.LookPath(name); err != nil {
d.Skipf("%s needed to run", name)
}
}
}
func (d *driverBase) tempDir(name string) string {
d.Helper()
dir, err := os.MkdirTemp("", name)
if err != nil {
d.Fatal(err)
}
d.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
func (d *driverBase) gogio(args ...string) {
d.Helper()
prog, err := os.Executable()
if err != nil {
d.Fatal(err)
}
cmd := exec.Command(prog, args...)
cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("gogio error: %s:\n%s", err, out)
}
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
Usage:
gogio -target <target> [flags] <package> [run arguments]
The gogio tool builds and packages Gio programs for platforms where additional
metadata or support files are required.
The package argument specifies an import path or a single Go source file to
package. Any run arguments are appended to os.Args at runtime.
Compiled Java class files from jar files in the package directory are
included in Android builds.
The mandatory -target flag selects the target platform: ios or android for the
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
MacOS and windows for Windows.
The -arch flag specifies a comma separated list of GOARCHs to include. The
default is all supported architectures.
The -o flag specifies an output file or directory, depending on the target.
The -buildmode flag selects the build mode. Two build modes are available, exe
and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
for Android or a directory with the WebAssembly module and support files for
a browser.
The -ldflags and -tags flags pass extra linker flags and tags to the go tool.
As a special case for iOS or tvOS, specifying a path that ends with ".app"
will output an app directory suitable for a simulator.
The other buildmode is archive, which will output an .aar library for Android
or a .framework for iOS and tvOS.
The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
If left unspecified, the appicon.png file from the main package is used
(if it exists).
The -appid flag specifies the package name for Android or the bundle id for
iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
tool can use it.
The -version flag specifies the integer version code for Android and the last
component of the 1.0.X version for iOS and tvOS.
For Android builds the -minsdk flag specify the minimum SDK level. For example,
use -minsdk 22 to target Android 5.1 (Lollipop) and later.
For Windows builds the -minsdk flag specify the minimum OS version. For example,
use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.
For iOS builds the -minsdk flag specify the minimum iOS version. For example,
use -mindk 15 to target iOS 15.0 and later.
For Android builds the -targetsdk flag specify the target SDK level. For example,
use -targetsdk 33 to target Android 13 (Tiramisu) and later.
The -work flag prints the path to the working directory and suppress
its deletion.
The -x flag will print all the external commands executed by the gogio tool.
The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
or specifies the name of key on Keychain to sign MacOS app.
The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
The -notaryid flag specifies the Apple ID to use for notarization of MacOS app.
The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not
provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397
for details. If not provided, the password will be prompted.
The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if
-notaryid is not provided.
`
+371
View File
@@ -0,0 +1,371 @@
// SPDX-License-Identifier: Unlicense OR MIT
//go:build linux
// +build linux
// This program demonstrates the use of a custom OpenGL ES context with
// app.Window.
package main
import (
"errors"
"fmt"
"image"
"image/color"
"log"
"os"
"runtime"
"strings"
"unsafe"
"gioui.org/app"
"gioui.org/gpu"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
/*
#cgo linux pkg-config: egl wayland-egl
#cgo freebsd openbsd CFLAGS: -I/usr/local/include
#cgo openbsd CFLAGS: -I/usr/X11R6/include
#cgo freebsd LDFLAGS: -L/usr/local/lib
#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
#cgo freebsd openbsd LDFLAGS: -lwayland-egl
#cgo CFLAGS: -DEGL_NO_X11
#cgo LDFLAGS: -lEGL -lGLESv2
#include <EGL/egl.h>
#include <wayland-client.h>
#include <wayland-egl.h>
#include <GLES3/gl3.h>
#define EGL_EGLEXT_PROTOTYPES
#include <EGL/eglext.h>
*/
import "C"
func getDisplay(ve app.ViewEvent) C.EGLDisplay {
switch ve := ve.(type) {
case app.X11ViewEvent:
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
case app.WaylandViewEvent:
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
}
panic("no display available")
}
func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
switch e := e.(type) {
case app.X11ViewEvent:
return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
case app.WaylandViewEvent:
eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
C.wl_egl_window_destroy(eglWin)
}
}
panic("no native view available")
}
type (
C = layout.Context
D = layout.Dimensions
)
type notifyFrame int
const (
notifyNone notifyFrame = iota
notifyInvalidate
notifyPrint
)
// notify keeps track of whether we want to print to stdout to notify the user
// when a frame is ready. Initially we want to notify about the first frame.
var notify = notifyInvalidate
type eglContext struct {
disp C.EGLDisplay
ctx C.EGLContext
surf C.EGLSurface
cleanup func()
}
func main() {
go func() {
// Set CustomRenderer so we can provide our own rendering context.
w := new(app.Window)
w.Option(app.CustomRenderer(true))
if err := loop(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func loop(w *app.Window) error {
var ops op.Ops
var (
ctx *eglContext
gioCtx gpu.GPU
ve app.ViewEvent
init bool
size image.Point
)
recreateContext := func() {
w.Run(func() {
if gioCtx != nil {
gioCtx.Release()
gioCtx = nil
}
if ctx != nil {
C.eglMakeCurrent(ctx.disp, nil, nil, nil)
ctx.Release()
ctx = nil
}
c, err := createContext(ve, size)
if err != nil {
log.Fatal(err)
}
ctx = c
})
if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
log.Fatal(err)
}
glGetString := func(e C.GLenum) string {
return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
}
fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
var err error
gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
if err != nil {
log.Fatal(err)
}
}
topLeft := quarterWidget{
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
}
topRight := quarterWidget{
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
}
botLeft := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
}
botRight := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
}
// eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
runtime.LockOSThread()
for {
switch e := w.Event().(type) {
case app.ViewEvent:
ve = e
init = true
if size != (image.Point{}) {
recreateContext()
}
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
if init && size != e.Size {
size = e.Size
recreateContext()
}
if gioCtx == nil || !init {
break
}
// Build ops.
gtx := app.NewContext(&ops, e)
// Clear background to white, even on embedded platforms such as webassembly.
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r1c1
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
// r1c2
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r2c1
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
// r2c2
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
)
}),
)
gtx.Execute(op.InvalidateCmd{})
log.Println("frame")
// Trigger window resize detection in ANGLE.
C.eglWaitClient()
// Draw custom OpenGL content.
drawGL()
// Render drawing ops.
if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
log.Fatal(fmt.Errorf("render failed: %v", err))
}
// Process non-drawing ops.
e.Frame(gtx.Ops)
switch notify {
case notifyInvalidate:
notify = notifyPrint
w.Invalidate()
case notifyPrint:
notify = notifyNone
fmt.Println("gio frame ready")
}
if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
}
}
}
return nil
}
func drawGL() {
C.glClearColor(0, 0, 0, 1)
C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
}
func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
view, cleanup := nativeViewFor(ve, size)
var nilv C.EGLNativeWindowType
if view == nilv {
return nil, fmt.Errorf("failed creating native view")
}
disp := getDisplay(ve)
if disp == 0 {
return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
}
var major, minor C.EGLint
if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
}
exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
attribs := []C.EGLint{
C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
C.EGL_BLUE_SIZE, 8,
C.EGL_GREEN_SIZE, 8,
C.EGL_RED_SIZE, 8,
C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
}
if srgb {
// Some drivers need alpha for sRGB framebuffers to work.
attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
}
attribs = append(attribs, C.EGL_NONE)
var (
cfg C.EGLConfig
numCfgs C.EGLint
)
if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
}
if numCfgs == 0 {
supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
if !supportsNoCfg {
return nil, errors.New("eglChooseConfig returned no configs")
}
}
ctxAttribs := []C.EGLint{
C.EGL_CONTEXT_CLIENT_VERSION, 3,
C.EGL_NONE,
}
ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
if ctx == nil {
return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
}
var surfAttribs []C.EGLint
if srgb {
surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
}
surfAttribs = append(surfAttribs, C.EGL_NONE)
surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
if surf == nil {
return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
}
return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
}
func (c *eglContext) Release() {
if c.ctx != nil {
C.eglDestroyContext(c.disp, c.ctx)
}
if c.surf != nil {
C.eglDestroySurface(c.disp, c.surf)
}
if c.cleanup != nil {
c.cleanup()
}
*c = eglContext{}
}
func hasExtension(exts []string, ext string) bool {
for _, e := range exts {
if ext == e {
return true
}
}
return false
}
// quarterWidget paints a quarter of the screen with one color. When clicked, it
// turns red, going back to its normal color when clicked again.
type quarterWidget struct {
color color.NRGBA
clicked bool
}
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
var color color.NRGBA
if w.clicked {
color = red
} else {
color = w.color
}
r := image.Rectangle{Max: gtx.Constraints.Max}
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
defer clip.Rect(image.Rectangle{
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, w)
for {
e, ok := gtx.Event(pointer.Filter{
Target: w,
Kinds: pointer.Press,
})
if !ok {
break
}
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
w.clicked = !w.clicked
// notify when we're done updating the frame.
notify = notifyInvalidate
}
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: Unlicense OR MIT
// A simple app used for gogio's end-to-end tests.
package main
import (
"fmt"
"image"
"image/color"
"log"
"gioui.org/app"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
func main() {
go func() {
w := new(app.Window)
if err := loop(w); err != nil {
log.Fatal(err)
}
}()
app.Main()
}
type notifyFrame int
const (
notifyNone notifyFrame = iota
notifyInvalidate
notifyPrint
)
// notify keeps track of whether we want to print to stdout to notify the user
// when a frame is ready. Initially we want to notify about the first frame.
var notify = notifyInvalidate
type (
C = layout.Context
D = layout.Dimensions
)
func loop(w *app.Window) error {
topLeft := quarterWidget{
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
}
topRight := quarterWidget{
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
}
botLeft := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
}
botRight := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
}
var ops op.Ops
for {
e := w.Event()
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
// Clear background to white, even on embedded platforms such as webassembly.
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r1c1
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
// r1c2
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r2c1
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
// r2c2
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
)
}),
)
e.Frame(gtx.Ops)
switch notify {
case notifyInvalidate:
notify = notifyPrint
w.Invalidate()
case notifyPrint:
notify = notifyNone
fmt.Println("gio frame ready")
}
}
}
}
// quarterWidget paints a quarter of the screen with one color. When clicked, it
// turns red, going back to its normal color when clicked again.
type quarterWidget struct {
color color.NRGBA
clicked bool
}
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
var color color.NRGBA
if w.clicked {
color = red
} else {
color = w.color
}
r := image.Rectangle{Max: gtx.Constraints.Max}
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
defer clip.Rect(image.Rectangle{
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, w)
filter := pointer.Filter{
Target: w,
Kinds: pointer.Press,
}
for {
e, ok := gtx.Event(filter)
if !ok {
break
}
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
w.clicked = !w.clicked
// notify when we're done updating the frame.
notify = notifyInvalidate
}
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
+546
View File
@@ -0,0 +1,546 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"archive/zip"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sync/errgroup"
)
const (
minIOSVersion = 10
// Some Metal features require tvOS 11
minTVOSVersion = 11
// Metal is available from iOS 8 on devices, yet from version 13 on the
// simulator.
minSimulatorVersion = 13
)
func buildIOS(tmpDir, target string, bi *buildInfo) error {
appName := bi.name
switch *buildMode {
case "archive":
framework := *destPath
if framework == "" {
framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
}
return archiveIOS(tmpDir, target, framework, bi)
case "exe":
out := *destPath
if out == "" {
out = appName + ".ipa"
}
forDevice := strings.HasSuffix(out, ".ipa")
// Filter out unsupported architectures.
for i := len(bi.archs) - 1; i >= 0; i-- {
switch bi.archs[i] {
case "arm", "arm64":
if forDevice {
continue
}
case "386", "amd64":
if !forDevice {
continue
}
}
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
}
if !forDevice && !strings.HasSuffix(out, ".app") {
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
}
if !forDevice {
return exeIOS(tmpDir, target, out, bi)
}
payload := filepath.Join(tmpDir, "Payload")
appDir := filepath.Join(payload, appName+".app")
if err := os.MkdirAll(appDir, 0755); err != nil {
return err
}
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
return err
}
if err := signIOS(bi, tmpDir, appDir); err != nil {
return err
}
return zipDir(out, tmpDir, "Payload")
default:
panic("unreachable")
}
}
func signIOS(bi *buildInfo, tmpDir, app string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
provisions, err := filepath.Glob(provPattern)
if err != nil {
return err
}
provInfo := filepath.Join(tmpDir, "provision.plist")
var avail []string
for _, prov := range provisions {
// Decode the provision file to a plist.
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
if err != nil {
return err
}
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
if err != nil {
return err
}
exp, err := time.Parse(time.UnixDate, expUnix)
if err != nil {
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
}
if exp.Before(time.Now()) {
continue
}
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
if err != nil {
return err
}
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
if err != nil {
return err
}
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
avail = append(avail, provAppID)
if expAppID != provAppID {
continue
}
// Copy provisioning file.
embedded := filepath.Join(app, "embedded.mobileprovision")
if err := copyFile(embedded, prov); err != nil {
return err
}
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
if err != nil {
return err
}
// Omit trailing newline.
certDER = certDER[:len(certDER)-1]
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
if err != nil {
return err
}
entFile := filepath.Join(tmpDir, "entitlements.plist")
if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
return err
}
identity := sha1.Sum(certDER)
idHex := hex.EncodeToString(identity[:])
_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
return err
}
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
}
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
if bi.appID == "" {
return errors.New("app id is empty; use -appid to set it")
}
if err := os.RemoveAll(app); err != nil {
return err
}
if err := os.Mkdir(app, 0755); err != nil {
return err
}
appName := UppercaseName(bi.name)
exe := filepath.Join(app, appName)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
if err != nil {
return err
}
cflags = append(cflags,
"-fobjc-arc",
)
cflagsLine := strings.Join(cflags, " ")
exeSlice := filepath.Join(tmpDir, "app-"+a)
lipo.Args = append(lipo.Args, exeSlice)
compile := exec.Command(
"go",
"build",
"-ldflags=-s -w "+bi.ldflags,
"-o", exeSlice,
"-tags", bi.tags,
bi.pkgPath,
)
compile.Env = append(
os.Environ(),
"GOOS=ios",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS="+cflagsLine,
"CGO_LDFLAGS=-lresolv "+cflagsLine,
)
builds.Go(func() error {
_, err := runCmd(compile)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
infoPlist := buildInfoPlist(bi)
plistFile := filepath.Join(app, "Info.plist")
if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
return err
}
if _, err := os.Stat(bi.iconPath); err == nil {
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
if err != nil {
return err
}
// Merge assets plist with Info.plist
cmd := exec.Command(
"/usr/libexec/PlistBuddy",
"-c", "Merge "+assetPlist,
plistFile,
)
if _, err := runCmd(cmd); err != nil {
return err
}
}
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
return err
}
return nil
}
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
// iosIcons returns the asset plist file to be merged into Info.plist.
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
assets := filepath.Join(tmpDir, "Assets.xcassets")
if err := os.Mkdir(assets, 0700); err != nil {
return "", err
}
appIcon := filepath.Join(assets, "AppIcon.appiconset")
err := buildIcons(appIcon, icon, []iconVariant{
{path: "ios_2x.png", size: 120},
{path: "ios_3x.png", size: 180},
// The App Store icon is not allowed to contain
// transparent pixels.
{path: "ios_store.png", size: 1024, fill: true},
})
if err != nil {
return "", err
}
contentJson := `{
"images" : [
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_3x.png",
"scale" : "3x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "ios_store.png",
"scale" : "1x"
}
]
}`
contentFile := filepath.Join(appIcon, "Contents.json")
if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
return "", err
}
assetPlist := filepath.Join(tmpDir, "assets.plist")
minsdk := bi.minsdk
if minsdk == 0 {
minsdk = minIOSVersion
}
compile := exec.Command(
"actool",
"--compile", appDir,
"--platform", iosPlatformFor(bi.target),
"--minimum-deployment-target", strconv.Itoa(minsdk),
"--app-icon", "AppIcon",
"--output-partial-info-plist", assetPlist,
assets)
_, err = runCmd(compile)
return assetPlist, err
}
func buildInfoPlist(bi *buildInfo) string {
appName := UppercaseName(bi.name)
platform := iosPlatformFor(bi.target)
var supportPlatform string
switch bi.target {
case "ios":
supportPlatform = "iPhoneOS"
case "tvos":
supportPlatform = "AppleTVOS"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>%s</string>
<key>CFBundleIdentifier</key>
<string>%s</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>%s</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>%s</string>
<key>CFBundleVersion</key>
<string>%d</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>DTPlatformName</key>
<string>%s</string>
<key>DTPlatformVersion</key>
<string>12.4</string>
<key>MinimumOSVersion</key>
<string>%d</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>%s</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>16G73</string>
<key>DTSDKBuild</key>
<string>16G73</string>
<key>DTSDKName</key>
<string>%s12.4</string>
<key>DTXcode</key>
<string>1030</string>
<key>DTXcodeBuild</key>
<string>10G8</string>
</dict>
</plist>`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
}
func iosPlatformFor(target string) string {
switch target {
case "ios":
return "iphoneos"
case "tvos":
return "appletvos"
default:
panic("invalid platform " + target)
}
}
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
framework := filepath.Base(frameworkRoot)
const suf = ".framework"
if !strings.HasSuffix(framework, suf) {
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
}
framework = framework[:len(framework)-len(suf)]
if err := os.RemoveAll(frameworkRoot); err != nil {
return err
}
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
for _, dir := range []string{"Headers", "Modules"} {
p := filepath.Join(frameworkDir, dir)
if err := os.MkdirAll(p, 0755); err != nil {
return err
}
}
symlinks := [][2]string{
{"Versions/Current/Headers", "Headers"},
{"Versions/Current/Modules", "Modules"},
{"Versions/Current/" + framework, framework},
{"A", filepath.Join("Versions", "Current")},
}
for _, l := range symlinks {
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
return err
}
}
exe := filepath.Join(frameworkDir, framework)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
tags := bi.tags
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
if err != nil {
return err
}
lib := filepath.Join(tmpDir, "gio-"+a)
cmd := exec.Command(
"go",
"build",
"-ldflags=-s -w "+bi.ldflags,
"-buildmode=c-archive",
"-o", lib,
"-tags", tags,
bi.pkgPath,
)
lipo.Args = append(lipo.Args, lib)
cflagsLine := strings.Join(cflags, " ")
cmd.Env = append(
os.Environ(),
"GOOS=ios",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS="+cflagsLine,
"CGO_LDFLAGS="+cflagsLine,
)
builds.Go(func() error {
_, err := runCmd(cmd)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
if err != nil {
return err
}
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
headerSrc := filepath.Join(appDir, "framework_ios.h")
if err := copyFile(headerDst, headerSrc); err != nil {
return err
}
module := fmt.Sprintf(`framework module "%s" {
header "%[1]s.h"
export *
}`, framework)
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
return os.WriteFile(moduleFile, []byte(module), 0644)
}
func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
var (
platformSDK string
platformOS string
)
switch target {
case "ios":
platformOS = "ios"
platformSDK = "iphone"
case "tvos":
platformOS = "tvos"
platformSDK = "appletv"
}
switch arch {
case "arm", "arm64":
platformSDK += "os"
if minsdk == 0 {
minsdk = minIOSVersion
if target == "tvos" {
minsdk = minTVOSVersion
}
}
case "386", "amd64":
platformOS += "-simulator"
platformSDK += "simulator"
if minsdk == 0 {
minsdk = minSimulatorVersion
}
default:
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
}
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
if err != nil {
return "", nil, err
}
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
if err != nil {
return "", nil, err
}
cflags := []string{
"-fembed-bitcode",
"-arch", allArchs[arch].iosArch,
"-isysroot", sdkPath,
"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
}
return clang, cflags, nil
}
func zipDir(dst, base, dir string) (err error) {
f, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
zipf := zip.NewWriter(f)
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
rel := filepath.ToSlash(path[len(base)+1:])
entry, err := zipf.Create(rel)
if err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(entry, src)
return err
})
if err != nil {
return err
}
return zipf.Close()
}
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bytes"
"context"
"errors"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
)
type JSTestDriver struct {
driverBase
// ctx is the chromedp context.
ctx context.Context
}
func (d *JSTestDriver) Start(path string) {
if raceEnabled {
d.Skipf("js/wasm doesn't support -race; skipping")
}
// First, build the app.
dir := d.tempDir("gio-endtoend-js")
d.gogio("-target=js", "-o="+dir, path)
// Second, start Chrome.
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", *headless),
)
actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
d.Cleanup(cancel)
ctx, cancel := chromedp.NewContext(actx,
// Send all logf/errf calls to t.Logf
chromedp.WithLogf(d.Logf),
)
d.Cleanup(cancel)
d.ctx = ctx
if err := chromedp.Run(ctx); err != nil {
if errors.Is(err, exec.ErrNotFound) {
d.Skipf("test requires Chrome to be installed: %v", err)
return
}
d.Fatal(err)
}
pr, pw := io.Pipe()
d.Cleanup(func() { pw.Close() })
d.output = pr
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
switch ev.Type {
case "log", "info", "warning", "error":
var b bytes.Buffer
b.WriteString("console.")
b.WriteString(string(ev.Type))
b.WriteString("(")
for i, arg := range ev.Args {
if i > 0 {
b.WriteString(", ")
}
b.Write(arg.Value)
}
b.WriteString(")\n")
pw.Write(b.Bytes())
}
}
})
// Third, serve the app folder, set the browser tab dimensions, and
// navigate to the folder.
ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
d.Cleanup(ts.Close)
if err := chromedp.Run(ctx,
chromedp.EmulateViewport(int64(d.width), int64(d.height)),
chromedp.Navigate(ts.URL),
); err != nil {
d.Fatal(err)
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *JSTestDriver) Screenshot() image.Image {
var buf []byte
if err := chromedp.Run(d.ctx,
chromedp.CaptureScreenshot(&buf),
); err != nil {
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(buf))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *JSTestDriver) Click(x, y int) {
if err := chromedp.Run(d.ctx,
chromedp.MouseClickXY(float64(x), float64(y)),
); err != nil {
d.Fatal(err)
}
// Wait for the gio app to render after this click.
d.waitForFrame()
}
+200
View File
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"golang.org/x/tools/go/packages"
)
func buildJS(bi *buildInfo) error {
out := *destPath
if out == "" {
out = bi.name
}
if err := os.MkdirAll(out, 0700); err != nil {
return err
}
cmd := exec.Command(
"go",
"build",
"-ldflags="+bi.ldflags,
"-tags="+bi.tags,
"-o", filepath.Join(out, "main.wasm"),
bi.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=js",
"GOARCH=wasm",
)
_, err := runCmd(cmd)
if err != nil {
return err
}
var faviconPath string
if _, err := os.Stat(bi.iconPath); err == nil {
// Copy icon to the output folder
icon, err := os.ReadFile(bi.iconPath)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
return err
}
faviconPath = filepath.Base(bi.iconPath)
}
indexTemplate, err := template.New("").Parse(jsIndex)
if err != nil {
return err
}
var b bytes.Buffer
if err := indexTemplate.Execute(&b, struct {
Name string
Icon string
}{
Name: bi.name,
Icon: faviconPath,
}); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil {
return err
}
goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
if err != nil {
return err
}
wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
if _, err := os.Stat(wasmJS); err != nil {
return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
}
pkgs, err := packages.Load(&packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
}, bi.pkgPath)
if err != nil {
return err
}
extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
if err != nil {
return err
}
return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
}
func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
if len(p.GoFiles) == 0 {
return nil, nil
}
js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
if err != nil {
return nil, err
}
extraJS = append(extraJS, js...)
for _, imp := range p.Imports {
if !visited[imp.ID] {
extra, err := findPackagesJS(imp, visited)
if err != nil {
return nil, err
}
extraJS = append(extraJS, extra...)
visited[imp.ID] = true
}
}
return extraJS, nil
}
// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
// and append the jsStartGo.
func mergeJSFiles(dst string, files ...string) (err error) {
w, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := w.Close(); err != nil {
err = cerr
}
}()
_, err = io.Copy(w, strings.NewReader(jsSetGo))
if err != nil {
return err
}
for i := range files {
r, err := os.Open(files[i])
if err != nil {
return err
}
_, err = io.Copy(w, r)
r.Close()
if err != nil {
return err
}
}
_, err = io.Copy(w, strings.NewReader(jsStartGo))
return err
}
const (
jsIndex = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
{{ if .Icon }}<link rel="icon" href="{{.Icon}}" type="image/x-icon" />{{ end }}
{{ if .Name }}<title>{{.Name}}</title>{{ end }}
<script src="wasm.js"></script>
<style>
body,pre { margin:0;padding:0; }
</style>
</head>
<body>
</body>
</html>`
// jsSetGo sets the `window.go` variable.
jsSetGo = `(() => {
window.go = {argv: [], env: {}, importObject: {go: {}}};
const argv = new URLSearchParams(location.search).get("argv");
if (argv) {
window.go["argv"] = argv.split(" ");
}
})();`
// jsStartGo initializes the main.wasm.
jsStartGo = `(() => {
defaultGo = new Go();
Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
Object.assign(defaultGo["env"], go["env"]);
for (let key in go["importObject"]) {
if (typeof defaultGo["importObject"][key] === "undefined") {
defaultGo["importObject"][key] = {};
}
Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
}
window.go = defaultGo;
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
})();`
)
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
)
func buildMac(tmpDir string, bi *buildInfo) error {
builder := &macBuilder{TempDir: tmpDir}
builder.DestDir = *destPath
if builder.DestDir == "" {
builder.DestDir = bi.pkgPath
}
name := bi.name
if *destPath != "" {
if filepath.Ext(*destPath) != ".app" {
return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
}
name = filepath.Base(*destPath)
}
name = strings.TrimSuffix(name, ".app")
if bi.appID == "" {
return errors.New("app id is empty; use -appid to set it")
}
if err := builder.setIcon(bi.iconPath); err != nil {
return err
}
if err := builder.setInfo(bi, name); err != nil {
return fmt.Errorf("can't build the resources: %v", err)
}
for _, arch := range bi.archs {
tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir))
finalDest := builder.DestDir
if len(bi.archs) > 1 {
tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app")
finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app")
}
if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil {
return err
}
if bi.key != "" {
if err := builder.signProgram(bi, tmpDest, name, arch); err != nil {
return err
}
}
if err := dittozip(tmpDest, tmpDest+".zip"); err != nil {
return err
}
if bi.notaryAppleID != "" {
if err := builder.notarize(bi, tmpDest+".zip"); err != nil {
return err
}
}
if err := dittounzip(tmpDest+".zip", finalDest); err != nil {
return err
}
}
return nil
}
type macBuilder struct {
TempDir string
DestDir string
Icons []byte
Manifest []byte
Entitlements []byte
}
func (b *macBuilder) setIcon(path string) (err error) {
if _, err := os.Stat(path); err != nil {
return nil
}
out := filepath.Join(b.TempDir, "iconset.iconset")
if err := os.MkdirAll(out, 0777); err != nil {
return err
}
err = buildIcons(out, path, []iconVariant{
{path: "icon_512x512@2x.png", size: 1024},
{path: "icon_512x512.png", size: 512},
{path: "icon_256x256@2x.png", size: 512},
{path: "icon_256x256.png", size: 256},
{path: "icon_128x128@2x.png", size: 256},
{path: "icon_128x128.png", size: 128},
{path: "icon_64x64@2x.png", size: 128},
{path: "icon_64x64.png", size: 64},
{path: "icon_32x32@2x.png", size: 64},
{path: "icon_32x32.png", size: 32},
{path: "icon_16x16@2x.png", size: 32},
{path: "icon_16x16.png", size: 16},
})
if err != nil {
return err
}
cmd := exec.Command("iconutil",
"-c", "icns", out,
"-o", filepath.Join(b.TempDir, "icon.icns"))
if _, err := runCmd(cmd); err != nil {
return err
}
b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
return err
}
func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{{.Name}}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>{{.Bundle}}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>`)
if err != nil {
return err
}
var manifest bufferCoff
if err := t.Execute(&manifest, struct {
Name, Bundle string
}{
Name: name,
Bundle: buildInfo.appID,
}); err != nil {
return err
}
b.Manifest = manifest.Bytes()
b.Entitlements = []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>`)
return nil
}
func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil {
return err
}
}
if len(b.Icons) > 0 {
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
return err
}
}
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
return err
}
cmd := exec.Command(
"go",
"build",
"-ldflags="+buildInfo.ldflags,
"-tags="+buildInfo.tags,
"-o", filepath.Join(binDest, "/Contents/MacOS/"+name),
buildInfo.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=darwin",
"GOARCH="+arch,
"CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
)
_, err := runCmd(cmd)
return err
}
func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
options := filepath.Join(b.TempDir, "ent.ent")
if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
return err
}
xattr := exec.Command("xattr", "-rc", binDest)
if _, err := runCmd(xattr); err != nil {
return err
}
cmd := exec.Command(
"codesign",
"--deep",
"--force",
"--options", "runtime",
"--entitlements", options,
"--sign", buildInfo.key,
binDest,
)
_, err := runCmd(cmd)
return err
}
func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error {
cmd := exec.Command(
"xcrun",
"notarytool",
"submit",
binDest,
"--apple-id", buildInfo.notaryAppleID,
"--team-id", buildInfo.notaryTeamID,
"--wait",
)
if buildInfo.notaryPassword != "" {
cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword)
}
_, err := runCmd(cmd)
return err
}
func dittozip(input, output string) error {
cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output)
_, err := runCmd(cmd)
return err
}
func dittounzip(input, output string) error {
cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output)
_, err := runCmd(cmd)
return err
}
+230
View File
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/image/draw"
"golang.org/x/sync/errgroup"
)
var (
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android")
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
name = flag.String("name", "", "app name (for -buildmode=exe)")
version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode")
printCommands = flag.Bool("x", false, "print the commands")
keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker")
extraTags = flag.String("tags", "", "extra tags to the Go tool")
iconPath = flag.String("icon", "", "specify an icon for iOS and Android")
signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.")
notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.")
notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.")
notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.")
)
func main() {
flag.Usage = func() {
fmt.Fprint(os.Stderr, mainUsage)
}
flag.Parse()
if err := flagValidate(); err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
buildInfo, err := newBuildInfo(flag.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
if err := build(buildInfo); err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func flagValidate() error {
pkgPathArg := flag.Arg(0)
if pkgPathArg == "" {
return errors.New("specify a package")
}
if *target == "" {
return errors.New("please specify -target")
}
switch *target {
case "ios", "tvos", "android", "js", "windows", "macos":
default:
return fmt.Errorf("invalid -target %s", *target)
}
switch *buildMode {
case "archive", "exe":
default:
return fmt.Errorf("invalid -buildmode %s", *buildMode)
}
return nil
}
func build(bi *buildInfo) error {
tmpDir, err := os.MkdirTemp("", "gogio-")
if err != nil {
return err
}
if *keepWorkdir {
fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
} else {
defer os.RemoveAll(tmpDir)
}
switch *target {
case "js":
return buildJS(bi)
case "ios", "tvos":
return buildIOS(tmpDir, *target, bi)
case "android":
return buildAndroid(tmpDir, bi)
case "windows":
return buildWindows(tmpDir, bi)
case "macos":
return buildMac(tmpDir, bi)
default:
panic("unreachable")
}
}
func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
if *printCommands {
fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
}
out, err := cmd.Output()
if err == nil {
return out, nil
}
if err, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
}
return nil, err
}
func runCmd(cmd *exec.Cmd) (string, error) {
out, err := runCmdRaw(cmd)
return string(bytes.TrimSpace(out)), err
}
func copyFile(dst, src string) (err error) {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := w.Close(); err == nil {
err = cerr
}
}()
_, err = io.Copy(w, r)
return err
}
type arch struct {
iosArch string
jniArch string
clangArch string
}
var allArchs = map[string]arch{
"arm": {
iosArch: "armv7",
jniArch: "armeabi-v7a",
clangArch: "armv7a-linux-androideabi",
},
"arm64": {
iosArch: "arm64",
jniArch: "arm64-v8a",
clangArch: "aarch64-linux-android",
},
"386": {
iosArch: "i386",
jniArch: "x86",
clangArch: "i686-linux-android",
},
"amd64": {
iosArch: "x86_64",
jniArch: "x86_64",
clangArch: "x86_64-linux-android",
},
}
type iconVariant struct {
path string
size int
fill bool
}
func buildIcons(baseDir, icon string, variants []iconVariant) error {
f, err := os.Open(icon)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
var resizes errgroup.Group
for _, v := range variants {
v := v
resizes.Go(func() (err error) {
path := filepath.Join(baseDir, v.path)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
return png.Encode(f, resizeIcon(v, img))
})
}
return resizes.Wait()
}
func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
op := draw.Src
if v.fill {
op = draw.Over
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
}
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
return scaled
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
if os.Getenv("RUN_GOGIO") != "" {
// Allow the end-to-end tests to call the gogio tool without
// having to build it from scratch, nor having to refactor the
// main function to avoid using global variables.
main()
os.Exit(0) // main already exits, but just in case.
}
os.Exit(m.Run())
}
+36
View File
@@ -0,0 +1,36 @@
package main
var AndroidPermissions = map[string][]string{
"network": {
"android.permission.INTERNET",
},
"networkstate": {
"android.permission.ACCESS_NETWORK_STATE",
},
"bluetooth": {
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.ACCESS_FINE_LOCATION",
},
"camera": {
"android.permission.CAMERA",
},
"storage": {
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
},
"wakelock": {
"android.permission.WAKE_LOCK",
},
}
var AndroidFeatures = map[string][]string{
"default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
"bluetooth": {
`name="android.hardware.bluetooth"`,
`name="android.hardware.bluetooth_le"`,
},
"camera": {
`name="android.hardware.camera"`,
},
}
+8
View File
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: Unlicense OR MIT
//go:build race
// +build race
package main_test
func init() { raceEnabled = true }
+196
View File
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bufio"
"bytes"
"context"
"fmt"
"image"
"image/png"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"text/template"
"time"
)
type WaylandTestDriver struct {
driverBase
runtimeDir string
socket string
display string
}
// No bars or anything fancy. Just a white background with our dimensions.
var tmplSwayConfig = template.Must(template.New("").Parse(`
output * bg #FFFFFF solid_color
output * mode {{.Width}}x{{.Height}}
default_border none
`))
var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`)
func (d *WaylandTestDriver) Start(path string) {
// We want os.Environ, so that it can e.g. find $DISPLAY to run within
// X11. wlroots env vars are documented at:
// https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md
env := os.Environ()
if *headless {
env = append(env, "WLR_BACKENDS=headless")
}
d.needPrograms(
"sway", // to run a wayland compositor
"grim", // to take screenshots
"swaymsg", // to send input
)
// First, build the app.
dir := d.tempDir("gio-endtoend-wayland")
bin := filepath.Join(dir, "red")
flags := []string{"build", "-tags", "nox11", "-o=" + bin}
if raceEnabled {
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
conf := filepath.Join(dir, "config")
f, err := os.Create(conf)
if err != nil {
d.Fatal(err)
}
defer f.Close()
if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{
d.width, d.height,
}); err != nil {
d.Fatal(err)
}
d.socket = filepath.Join(dir, "socket")
env = append(env, "SWAYSOCK="+d.socket)
d.runtimeDir = dir
env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir)
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
// First, start sway.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose")
cmd.Env = env
stderr, err := cmd.StderrPipe()
if err != nil {
d.Fatal(err)
}
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
d.Cleanup(func() {
// Give it a chance to exit gracefully, cleaning up
// after itself. After 10ms, the deferred cancel above
// will signal an os.Kill.
cmd.Process.Signal(os.Interrupt)
time.Sleep(10 * time.Millisecond)
})
// Wait for sway to be ready. We probably don't need a deadline
// here.
br := bufio.NewReader(stderr)
for {
line, err := br.ReadString('\n')
if err != nil {
d.Fatal(err)
}
if m := rxSwayReady.FindStringSubmatch(line); m != nil {
d.display = m[1]
break
}
}
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") {
// Don't print all stderr, since we use --verbose.
// TODO(mvdan): if it's useful, probably filter
// errors and show them.
d.Error(err)
}
wg.Done()
}()
}
// Then, start our program on the sway compositor above.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin)
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *WaylandTestDriver) Screenshot() image.Image {
cmd := exec.Command("grim", "/dev/stdout")
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *WaylandTestDriver) swaymsg(args ...interface{}) {
strs := []string{"--socket", d.socket}
for _, arg := range args {
strs = append(strs, fmt.Sprint(arg))
}
cmd := exec.Command("swaymsg", strs...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
}
func (d *WaylandTestDriver) Click(x, y int) {
d.swaymsg("seat", "-", "cursor", "set", x, y)
d.swaymsg("seat", "-", "cursor", "press", "button1")
d.swaymsg("seat", "-", "cursor", "release", "button1")
// Wait for the gio app to render after this click.
d.waitForFrame()
}
+152
View File
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"context"
"image"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
"golang.org/x/image/draw"
)
// Wine is tightly coupled with X11 at the moment, and we can reuse the same
// methods to automate screenshots and clicks. The main difference is how we
// build and run the app.
// The only quirk is that it seems impossible for the Wine window to take the
// entirety of the X server's dimensions, even if we try to resize it to take
// the entire display. It seems to want to leave some vertical space empty,
// presumably for window decorations or the "start" bar on Windows. To work
// around that, make the X server 50x50px bigger, and crop the screenshots back
// to the original size.
type WineTestDriver struct {
X11TestDriver
}
func (d *WineTestDriver) Start(path string) {
d.needPrograms("wine")
// First, build the app.
bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe")
flags := []string{"build", "-o=" + bin}
if raceEnabled {
if runtime.GOOS != "windows" {
// cross-compilation disables CGo, which breaks -race.
d.Skipf("can't cross-compile -race for Windows; skipping")
}
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS=windows")
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
// Add 50x50px to the display dimensions, as discussed earlier.
d.startServer(&wg, d.width+50, d.height+50)
// Then, start our program via Wine on the X server above.
{
cacheDir, err := os.UserCacheDir()
if err != nil {
d.Fatal(err)
}
// Use a wine directory separate from the default ~/.wine, so
// that the user's winecfg doesn't affect our test. This will
// default to ~/.cache/gio-e2e-wine. We use the user's cache,
// to reuse a previously set up wineprefix.
wineprefix := filepath.Join(cacheDir, "gio-e2e-wine")
// First, ensure that wineprefix is up to date with wineboot.
// Wait for this separately from the first frame, as setting up
// a new prefix might take 5s on its own.
env := []string{
"DISPLAY=" + d.display,
"WINEDEBUG=fixme-all", // hide "fixme" noise
"WINEPREFIX=" + wineprefix,
// Disable wine-gecko (Explorer) and wine-mono (.NET).
// Otherwise, if not installed, wineboot will get stuck
// with a prompt to install them on the virtual X
// display. Moreover, Gio doesn't need either, and wine
// is faster without them.
"WINEDLLOVERRIDES=mscoree,mshtml=",
}
{
start := time.Now()
cmd := exec.Command("wine", "wineboot", "-i")
cmd.Env = env
// Use a combined output pipe instead of CombinedOutput,
// so that we only wait for the child process to exit,
// and we don't need to wait for all of wine's
// grandchildren to exit and stop writing. This is
// relevant as wine leaves "wineserver" lingering for
// three seconds by default, to be reused later.
stdout, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
if err := cmd.Run(); err != nil {
io.Copy(os.Stderr, stdout)
d.Fatal(err)
}
d.Logf("set up WINEPREFIX in %s", time.Since(start))
}
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "wine", bin)
cmd.Env = env
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
// xdotool seems to fail at actually moving the window if we use it
// immediately after Gio is ready. Why?
// We can't tell if the windowmove operation worked until we take a
// screenshot, because the getwindowgeometry op reports the 0x0
// coordinates even if the window wasn't moved properly.
// A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that.
// TODO(mvdan): revisit this, when you have a spare three hours.
time.Sleep(400 * time.Millisecond)
id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio")
d.xdotool("windowmove", "--sync", id, 0, 0)
}
func (d *WineTestDriver) Screenshot() image.Image {
img := d.X11TestDriver.Screenshot()
// Crop the screenshot back to the original dimensions.
cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height))
draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src)
return cropped
}
+410
View File
@@ -0,0 +1,410 @@
package main
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"text/template"
"github.com/akavel/rsrc/binutil"
"github.com/akavel/rsrc/coff"
"golang.org/x/text/encoding/unicode"
)
func buildWindows(tmpDir string, bi *buildInfo) error {
builder := &windowsBuilder{TempDir: tmpDir}
builder.DestDir = *destPath
if builder.DestDir == "" {
builder.DestDir = bi.pkgPath
}
name := bi.name
if *destPath != "" {
if filepath.Ext(*destPath) != ".exe" {
return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
}
name = filepath.Base(*destPath)
}
name = strings.TrimSuffix(name, ".exe")
sdk := bi.minsdk
if sdk > 10 {
return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
}
for _, arch := range bi.archs {
builder.Coff = coff.NewRSRC()
builder.Coff.Arch(arch)
if err := builder.embedIcon(bi.iconPath); err != nil {
return err
}
if err := builder.embedManifest(windowsManifest{
Version: bi.version.String(),
WindowsVersion: sdk,
Name: name,
}); err != nil {
return fmt.Errorf("can't create manifest: %v", err)
}
if err := builder.embedInfo(windowsResources{
Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)},
VersionHuman: bi.version.String(),
Name: name,
Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10)
}); err != nil {
return fmt.Errorf("can't create info: %v", err)
}
if err := builder.buildResource(bi, name, arch); err != nil {
return fmt.Errorf("can't build the resources: %v", err)
}
if err := builder.buildProgram(bi, name, arch); err != nil {
return err
}
}
return nil
}
type (
windowsResources struct {
Version [2]uint32
VersionHuman string
Language uint16
Name string
}
windowsManifest struct {
Version string
WindowsVersion int
Name string
}
windowsBuilder struct {
TempDir string
DestDir string
Coff *coff.Coff
}
)
const (
// https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types
windowsResourceIcon = 3
windowsResourceIconGroup = windowsResourceIcon + 11
windowsResourceManifest = 24
windowsResourceVersion = 16
)
type bufferCoff struct {
bytes.Buffer
}
func (b *bufferCoff) Size() int64 {
return int64(b.Len())
}
func (b *windowsBuilder) embedIcon(path string) (err error) {
iconFile, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("can't read the icon located at %s: %v", path, err)
}
defer iconFile.Close()
iconImage, err := png.Decode(iconFile)
if err != nil {
return fmt.Errorf("can't decode the PNG file (%s): %v", path, err)
}
sizes := []int{16, 32, 48, 64, 128, 256}
var iconHeader bufferCoff
// GRPICONDIR structure.
if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
return err
}
for _, size := range sizes {
var iconBuffer bufferCoff
if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil {
return fmt.Errorf("can't encode image: %v", err)
}
b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer)
if err := binary.Write(&iconHeader, binary.LittleEndian, struct {
Size [2]uint8
Color [2]uint8
Planes uint16
BitCount uint16
Length uint32
Id uint16
}{
Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px.
Planes: 1,
BitCount: 32,
Length: uint32(iconBuffer.Len()),
Id: uint16(size),
}); err != nil {
return err
}
}
b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader)
return nil
}
func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error {
out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso"))
if err != nil {
return err
}
defer out.Close()
b.Coff.Freeze()
// See https://github.com/akavel/rsrc/internal/write.go#L13.
w := binutil.Writer{W: out}
binutil.Walk(b.Coff, func(v reflect.Value, path string) error {
if binutil.Plain(v.Kind()) {
w.WriteLE(v.Interface())
return nil
}
vv, ok := v.Interface().(binutil.SizedReader)
if ok {
w.WriteFromSized(vv)
return binutil.WALK_SKIP
}
return nil
})
if w.Err != nil {
return fmt.Errorf("error writing output file: %s", w.Err)
}
return nil
}
func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
dest := b.DestDir
if len(buildInfo.archs) > 1 {
dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe")
}
cmd := exec.Command(
"go",
"build",
"-ldflags=-H=windowsgui "+buildInfo.ldflags,
"-tags="+buildInfo.tags,
"-o", dest,
buildInfo.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=windows",
"GOARCH="+arch,
)
_, err := runCmd(cmd)
return err
}
func (b *windowsBuilder) embedManifest(v windowsManifest) error {
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="{{.Name}}" version="{{.Version}}" />
<description>{{.Name}}</description>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
{{if (le .WindowsVersion 10)}}<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
{{end}}
{{if (le .WindowsVersion 9)}}<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
{{end}}
{{if (le .WindowsVersion 8)}}<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
{{end}}
{{if (le .WindowsVersion 7)}}<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
{{end}}
{{if (le .WindowsVersion 6)}}<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
{{end}}
</application>
</compatibility>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>`)
if err != nil {
return err
}
var manifest bufferCoff
if err := t.Execute(&manifest, v); err != nil {
return err
}
b.Coff.AddResource(windowsResourceManifest, 1, &manifest)
return nil
}
func (b *windowsBuilder) embedInfo(v windowsResources) error {
page := uint16(1)
// https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo
t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo
windowsInfoValueFixed{
Signature: 0xFEEF04BD,
StructVersion: 0x00010000,
FileVersion: v.Version,
ProductVersion: v.Version,
FileFlagMask: 0x3F,
FileFlags: 0,
FileOS: 0x40004,
FileType: 0x1,
FileSubType: 0,
},
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo
newValue(valueText, "StringFileInfo", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable
newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str
newValue(valueText, "ProductVersion", v.VersionHuman),
newValue(valueText, "FileVersion", v.VersionHuman),
newValue(valueText, "FileDescription", v.Name),
newValue(valueText, "ProductName", v.Name),
// TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...)
}),
}),
// https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo
newValue(valueBinary, "VarFileInfo", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str
newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)),
}),
})
// For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`:
t.ValueLength = 52
var verrsrc bufferCoff
if _, err := t.WriteTo(&verrsrc); err != nil {
return err
}
b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc)
return nil
}
type windowsInfoValueFixed struct {
Signature uint32
StructVersion uint32
FileVersion [2]uint32
ProductVersion [2]uint32
FileFlagMask uint32
FileFlags uint32
FileOS uint32
FileType uint32
FileSubType uint32
FileDate [2]uint32
}
func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) {
return 0, binary.Write(w, binary.LittleEndian, v)
}
type windowsInfoValue struct {
Length uint16
ValueLength uint16
Type uint16
Key []byte
Value []byte
}
func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) {
// binary.Write doesn't support []byte inside struct.
if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil {
return 0, err
}
if _, err = w.Write(v.Key); err != nil {
return 0, err
}
if _, err = w.Write(v.Value); err != nil {
return 0, err
}
return 0, nil
}
const (
valueBinary uint16 = 0
valueText uint16 = 1
)
func newValue(valueType uint16, key string, input interface{}) windowsInfoValue {
v := windowsInfoValue{
Type: valueType,
Length: 6,
}
padding := func(in []byte) []byte {
if l := uint16(len(in)) + v.Length; l%4 != 0 {
return append(in, make([]byte, 4-l%4)...)
}
return in
}
v.Key = padding(utf16Encode(key))
v.Length += uint16(len(v.Key))
switch in := input.(type) {
case string:
v.Value = padding(utf16Encode(in))
v.ValueLength = uint16(len(v.Value) / 2)
case []io.WriterTo:
var buff bytes.Buffer
for k := range in {
if _, err := in[k].WriteTo(&buff); err != nil {
panic(err)
}
}
v.Value = buff.Bytes()
default:
var buff bytes.Buffer
if err := binary.Write(&buff, binary.LittleEndian, in); err != nil {
panic(err)
}
v.ValueLength = uint16(buff.Len())
v.Value = buff.Bytes()
}
v.Length += uint16(len(v.Value))
return v
}
// utf16Encode encodes the string to UTF16 with null-termination.
func utf16Encode(s string) []byte {
b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s))
if err != nil {
panic(err)
}
return append(b, 0x00, 0x00) // null-termination.
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
type X11TestDriver struct {
driverBase
display string
}
func (d *X11TestDriver) Start(path string) {
// First, build the app.
bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red")
flags := []string{"build", "-tags", "nowayland", "-o=" + bin}
if raceEnabled {
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
d.startServer(&wg, d.width, d.height)
// Then, start our program on the X server above.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin)
cmd.Env = []string{"DISPLAY=" + d.display}
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) {
// Pick a random display number between 1 and 100,000. Most machines
// will only be using :0, so there's only a 0.001% chance of two
// concurrent test runs to run into a conflict.
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1)
var xprog string
xflags := []string{
"-wr", // we want a white background; the default is black
}
if *headless {
xprog = "Xvfb" // virtual X server
xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height))
} else {
xprog = "Xephyr" // nested X server as a window
xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height))
}
xflags = append(xflags, d.display)
d.needPrograms(
xprog, // to run the X server
"scrot", // to take screenshots
"xdotool", // to send input
)
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, xprog, xflags...)
combined := &bytes.Buffer{}
cmd.Stdout = combined
cmd.Stderr = combined
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
d.Cleanup(func() {
// Give it a chance to exit gracefully, cleaning up
// after itself. After 10ms, the deferred cancel above
// will signal an os.Kill.
cmd.Process.Signal(os.Interrupt)
time.Sleep(10 * time.Millisecond)
})
// Wait for the X server to be ready. The socket path isn't
// terribly portable, but that's okay for now.
withRetries(d.T, time.Second, func() error {
socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:])
_, err := os.Stat(socket)
return err
})
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
// Print all output and error.
io.Copy(os.Stdout, combined)
d.Error(err)
}
wg.Done()
}()
}
func (d *X11TestDriver) Screenshot() image.Image {
cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout")
cmd.Env = []string{"DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *X11TestDriver) xdotool(args ...interface{}) string {
d.Helper()
strs := make([]string, len(args))
for i, arg := range args {
strs[i] = fmt.Sprint(arg)
}
cmd := exec.Command("xdotool", strs...)
cmd.Env = []string{"DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
return string(bytes.TrimSpace(out))
}
func (d *X11TestDriver) Click(x, y int) {
d.xdotool("mousemove", "--sync", x, y)
d.xdotool("click", "1")
// Wait for the gio app to render after this click.
d.waitForFrame()
}
+582
View File
@@ -0,0 +1,582 @@
// SPDX-License-Identifier: Unlicense OR MIT
// Command svg2gio converts SVG files to Gio functions. Only a limited subset of
// SVG files are supported.
package main
import (
"bytes"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"go/format"
"gioui.org/f32"
)
var (
pkg = flag.String("pkg", "", "Go package")
output = flag.String("o", "svg.go", "Output Go file")
)
func main() {
flag.Parse()
if *pkg == "" {
fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n")
os.Exit(1)
}
args := flag.Args()
if err := convertAll(args); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(2)
}
}
type Points []float32
func (p *Points) UnmarshalText(text []byte) error {
for {
text = bytes.TrimLeft(text, "\t\n")
if len(text) == 0 {
break
}
var num []byte
end := bytes.IndexAny(text, " ,")
if end != -1 {
num = text[:end]
text = text[end+1:]
} else {
num = text
text = nil
}
f, err := strconv.ParseFloat(string(num), 32)
if err != nil {
return err
}
*p = append(*p, float32(f))
}
return nil
}
type Transform f32.Affine2D
func (t *Transform) UnmarshalText(text []byte) error {
switch {
case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")):
trans := text[7 : len(text)-1]
var p Points
if err := p.UnmarshalText(trans); err != nil {
return err
}
if len(p) != 6 {
return fmt.Errorf("malformed transform matrix: %q", text)
}
*t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5]))
return nil
default:
return fmt.Errorf("unsupported transform: %q", text)
}
}
type Fill struct {
Transform Transform `xml:"transform,attr"`
Fill Color `xml:"fill,attr"`
Stroke Color `xml:"stroke,attr"`
StrokeLinejoin string `xml:"stroke-linejoin,attr"`
StrokeLinecap string `xml:"stroke-linecap,attr"`
StrokeWidth float32 `xml:"stroke-width,attr"`
}
type Color struct {
Set bool
Value int
}
func (c *Color) UnmarshalText(text []byte) error {
if string(text) == "none" {
*c = Color{}
return nil
}
if !bytes.HasPrefix(text, []byte("#")) {
return fmt.Errorf("invalid color: %q", text)
}
text = text[1:]
i, err := strconv.ParseInt(string(text), 16, 32)
// Implied alpha.
if len(text) == 6 {
i |= 0xff000000
}
*c = Color{
Set: true,
Value: int(i),
}
return err
}
func convertAll(files []string) error {
w := new(bytes.Buffer)
fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n")
fmt.Fprintf(w, "package %s\n\n", *pkg)
fmt.Fprintf(w, "import \"image/color\"\n")
fmt.Fprintf(w, "import \"math\"\n")
fmt.Fprintf(w, "import \"gioui.org/op\"\n")
fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n")
fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n")
fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n")
fmt.Fprintf(w, "var ops op.Ops\n\n")
fmt.Fprintf(w, funcs)
for _, filename := range files {
if err := convert(w, filename); err != nil {
return err
}
}
src, err := format.Source(w.Bytes())
if err != nil {
return err
}
return os.WriteFile(*output, src, 0o660)
}
func convert(w io.Writer, filename string) error {
base := filepath.Base(filename)
ext := filepath.Ext(base)
name := "Image_" + base[:len(base)-len(ext)]
fmt.Fprintf(w, "var %s struct {\n", name)
fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n")
fmt.Fprintf(w, "Call op.CallOp\n\n")
fmt.Fprintf(w, "}\n")
fmt.Fprintf(w, "func init() {\n")
defer fmt.Fprintf(w, "}\n")
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
d := xml.NewDecoder(f)
if err := parse(w, d, name); err != nil {
line, col := d.InputPos()
return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err)
}
return nil
}
func parse(w io.Writer, d *xml.Decoder, name string) error {
for {
tok, err := d.Token()
if err != nil {
if err == io.EOF {
return errors.New("unexpected end of file")
}
return err
}
switch tok := tok.(type) {
case xml.StartElement:
if n := tok.Name.Local; n != "svg" {
return fmt.Errorf("invalid SVG root: <%s>", n)
}
if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" {
return fmt.Errorf("unsupported SVG namespace: %s", n)
}
fmt.Fprintf(w, "m := op.Record(&ops)\n")
defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name)
for _, a := range tok.Attr {
if a.Name.Local == "viewBox" {
var p Points
if err := p.UnmarshalText([]byte(a.Value)); err != nil {
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
}
if len(p) != 4 {
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
}
fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1])))
fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3])))
}
}
return parseSVG(w, d)
}
}
}
func point(p f32.Point) string {
return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y)
}
type Poly struct {
XMLName xml.Name
Points Points `xml:"points,attr"`
Fill
}
func (p *Poly) Path(w io.Writer) error {
if len(p.Points) <= 1 {
return nil
}
pen := f32.Pt(p.Points[0], p.Points[1])
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen))
last := pen
for i := 2; i < len(p.Points); i += 2 {
last = f32.Pt(p.Points[i], p.Points[i+1])
fmt.Fprintf(w, "p.LineTo(%s)\n", point(last))
}
if p.XMLName.Local == "polygon" && last != pen {
fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen))
}
return nil
}
type Path struct {
D string `xml:"d,attr"`
Fill
}
func (p *Path) Path(w io.Writer) error {
return printPathCommands(w, p.D)
}
type Line struct {
X1 float32 `xml:"x1,attr"`
Y1 float32 `xml:"y1,attr"`
X2 float32 `xml:"x2,attr"`
Y2 float32 `xml:"y2,attr"`
Fill
}
func (l *Line) Path(w io.Writer) error {
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1)))
fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2)))
return nil
}
type Ellipse struct {
Cx float32 `xml:"cx,attr"`
Cy float32 `xml:"cy,attr"`
Rx float32 `xml:"rx,attr"`
Ry float32 `xml:"ry,attr"`
Fill
}
func (e *Ellipse) Path(w io.Writer) error {
c := f32.Pt(e.Cx, e.Cy)
r := f32.Pt(e.Rx, e.Ry)
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r))
return nil
}
type Rect struct {
X float32 `xml:"x,attr"`
Y float32 `xml:"y,attr"`
Width float32 `xml:"width,attr"`
Height float32 `xml:"height,attr"`
Fill
}
func (r *Rect) Path(w io.Writer) error {
o := f32.Pt(r.X, r.Y)
sz := f32.Pt(r.Width, r.Height)
fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz))
return nil
}
type Circle struct {
Cx float32 `xml:"cx,attr"`
Cy float32 `xml:"cy,attr"`
R float32 `xml:"r,attr"`
Fill
}
func (c *Circle) Path(w io.Writer) error {
center := f32.Pt(c.Cx, c.Cy)
r := f32.Pt(c.R, c.R)
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r))
return nil
}
func parseSVG(w io.Writer, d *xml.Decoder) error {
for {
tok, err := d.Token()
if err != nil {
if err == io.EOF {
return errors.New("unexpected end of <svg> element")
}
return err
}
var start xml.StartElement
switch tok := tok.(type) {
case xml.EndElement:
return nil
case xml.StartElement:
start = tok
default:
continue
}
var elem interface {
Path(w io.Writer) error
}
var fill *Fill
switch n := start.Name.Local; n {
case "g":
// Flatten groups.
if err := parseSVG(w, d); err != nil {
return err
}
continue
case "title":
d.Skip()
continue
case "polygon", "polyline":
p := new(Poly)
elem = p
fill = &p.Fill
case "path":
p := new(Path)
elem = p
fill = &p.Fill
case "line":
l := new(Line)
elem = l
fill = &l.Fill
case "ellipse":
e := new(Ellipse)
elem = e
fill = &e.Fill
case "rect":
r := new(Rect)
elem = r
fill = &r.Fill
case "circle":
c := new(Circle)
elem = c
fill = &c.Fill
default:
return fmt.Errorf("unsupported tag: <%s>", n)
}
if err := d.DecodeElement(elem, &start); err != nil {
return err
}
if !fill.Fill.Set && !fill.Stroke.Set {
continue
}
fmt.Fprintf(w, "{\n")
trans := f32.Affine2D(fill.Transform)
if trans != (f32.Affine2D{}) {
sx, hx, ox, sy, hy, oy := trans.Elems()
fmt.Fprintf(w, "t := op.Affine(f32.NewAffine2D(%g, %g, %g, %g, %g, %g)).Push(&ops)\n", sx, hx, ox, sy, hy, oy)
}
fmt.Fprintf(w, "var p clip.Path\n")
fmt.Fprintf(w, "p.Begin(&ops)\n")
if err := elem.Path(w); err != nil {
return err
}
fmt.Fprintf(w, "spec := p.End()\n")
if fill.Fill.Set {
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Outline{Path: spec}.Op())\n", fill.Fill.Value)
}
if fill.Stroke.Set {
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Stroke{Width: %g, Path: spec}.Op())\n", fill.Stroke.Value, fill.StrokeWidth)
}
if trans != (f32.Affine2D{}) {
fmt.Fprintf(w, "t.Pop()\n")
}
fmt.Fprintf(w, "}\n")
}
}
func printPathCommands(w io.Writer, cmds string) error {
moveTo := func(p f32.Point) {
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(p))
}
lineTo := func(p f32.Point) {
fmt.Fprintf(w, "p.LineTo(%s)\n", point(p))
}
cubeTo := func(p0, p1, p2 f32.Point) {
fmt.Fprintf(w, "p.CubeTo(%s, %s, %s)\n", point(p0), point(p1), point(p2))
}
cmds = strings.TrimSpace(cmds)
var pen f32.Point
initPoint := pen
ctrl2 := pen
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
orig := cmds
op := rune(cmds[0])
cmds = cmds[1:]
switch op {
case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's':
case 'Z', 'z':
if pen != initPoint {
lineTo(initPoint)
pen = initPoint
}
ctrl2 = initPoint
continue
default:
return fmt.Errorf("unknown <path> command %s in %q", string(op), orig)
}
var coords []float64
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
n, x, ok := parseFloat(cmds)
if !ok {
break
}
cmds = cmds[n:]
coords = append(coords, x)
}
rel := unicode.IsLower(op)
newPen := pen
switch unicode.ToLower(op) {
case 'h':
for _, x := range coords {
p := f32.Pt(float32(x), pen.Y)
if rel {
p.X += pen.X
}
lineTo(p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
case 'v':
for _, y := range coords {
p := f32.Pt(pen.X, float32(y))
if rel {
p.Y += pen.Y
}
lineTo(p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
}
if len(coords)%2 != 0 {
return fmt.Errorf("odd number of coordinates in <path> data: %q", orig)
}
var off f32.Point
if rel {
// Relative command.
off = pen
} else {
off = f32.Pt(0, 0)
}
var points []f32.Point
for i := 0; i < len(coords); i += 2 {
p := f32.Pt(float32(coords[i]), float32(coords[i+1]))
p = p.Add(off)
points = append(points, p)
}
newCtrl2 := ctrl2
switch op := unicode.ToLower(op); op {
case 'm', 'l':
sop := moveTo
if op == 'l' {
sop = lineTo
}
for _, p := range points {
sop(p)
newPen = p
}
if op == 'm' {
initPoint = newPen
}
case 'c':
for i := 0; i < len(points); i += 3 {
p1, p2, p3 := points[i], points[i+1], points[i+2]
cubeTo(p1, p2, p3)
newPen = p3
newCtrl2 = p2
}
case 's':
for i := 0; i < len(points); i += 2 {
p2, p3 := points[i], points[i+1]
// Compute p1 by reflecting p2 on to the line that contains pen and p2.
p1 := pen.Mul(2).Sub(ctrl2)
cubeTo(p1, p2, p3)
newPen = p3
newCtrl2 = p2
}
}
pen = newPen
ctrl2 = newCtrl2
}
return nil
}
func parseFloat(s string) (int, float64, bool) {
n := 0
if len(s) > 0 && s[0] == '-' {
n++
}
for ; n < len(s); n++ {
if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') {
break
}
}
f, err := strconv.ParseFloat(s[:n], 64)
return n, f, err == nil
}
const funcs = `
func argb(c uint32) color.NRGBA {
return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}
func rect(p *clip.Path, origin, size f32.Point) {
p.MoveTo(origin)
p.LineTo(origin.Add(f32.Pt(size.X, 0)))
p.LineTo(origin.Add(size))
p.LineTo(origin.Add(f32.Pt(0, size.Y)))
p.Close()
}
func ellipse(p *clip.Path, center, radius f32.Point) {
r := radius.X
// We'll model the ellipse as a circle scaled in the Y
// direction.
scale := radius.Y / r
// https://pomax.github.io/bezierinfo/#circles_cubic.
const q = 4 * (math.Sqrt2 - 1) / 3
curve := r * q
top := f32.Point{X: center.X, Y: center.Y - r*scale}
p.MoveTo(top)
p.CubeTo(
f32.Point{X: center.X + curve, Y: center.Y - r*scale},
f32.Point{X: center.X + r, Y: center.Y - curve*scale},
f32.Point{X: center.X + r, Y: center.Y},
)
p.CubeTo(
f32.Point{X: center.X + r, Y: center.Y + curve*scale},
f32.Point{X: center.X + curve, Y: center.Y + r*scale},
f32.Point{X: center.X, Y: center.Y + r*scale},
)
p.CubeTo(
f32.Point{X: center.X - curve, Y: center.Y + r*scale},
f32.Point{X: center.X - r, Y: center.Y + curve*scale},
f32.Point{X: center.X - r, Y: center.Y},
)
p.CubeTo(
f32.Point{X: center.X - r, Y: center.Y - curve*scale},
f32.Point{X: center.X - curve, Y: center.Y - r*scale},
top,
)
}
`
+25 -2
View File
@@ -19,26 +19,49 @@ type focusAppearance struct {
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
+1178
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"image"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
)
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if u.mode != "phone" {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.mode == "phone" {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
+36
View File
@@ -41,6 +41,7 @@ func (u *ui) attachmentInput() (string, []byte, error) {
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
@@ -175,6 +176,7 @@ func (u *ui) saveEntryAction() error {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -229,6 +231,7 @@ func (u *ui) saveTemplateAction() error {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -251,6 +254,14 @@ func (u *ui) createGroupAction() error {
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
@@ -400,6 +411,7 @@ func (u *ui) generatePasswordAction() error {
return err
}
u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil
}
@@ -481,3 +493,27 @@ func marshalFields(fields map[string]string) string {
}
return strings.Join(lines, "\n")
}
func (u *ui) clearGeneratedPasswordDraft() {
u.generatedPasswordDraft = false
}
func (u *ui) attachmentActionSummary() string {
items := u.selectedAttachmentItems()
if len(items) == 0 {
return "No attachments yet. Add one below to store a file with this entry."
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "Select an attachment above, then replace it, export it, or remove it below."
}
for _, item := range items {
if item.Name == name {
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
}
}
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
}
+1171 -251
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
return true
}
if u.isVaultLocked() && name == key.NameReturn {
u.runAction("unlock vault", u.unlockAction)
u.startUnlockAction()
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.runAction("open remote vault", u.openRemoteAction)
u.startOpenRemoteAction()
} else {
u.runAction("open vault", u.openVaultAction)
u.startOpenVaultAction()
}
return true
}
+300
View File
@@ -0,0 +1,300 @@
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,6 +37,8 @@ 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
@@ -48,6 +50,9 @@ 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: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Original note",
Path: []string{"Root", "Internet"},
},
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
}
model.UpsertEntry(Entry{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Updated note",
Path: []string{"Root", "Internet"},
})
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
}
if err := model.DeleteEntry("ha-codex"); err != nil {
if err := model.DeleteEntry("surveillance-console"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
}
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
}
}
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{
RecycleBin: []Entry{
{
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-3",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
if err := model.RestoreEntry("dynadot"); err != nil {
if err := model.RestoreEntry("bellagio"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
}
if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
Notes: "Current note",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "git-server-history-1",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console-history-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
Notes: "Previous note",
Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
},
}
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err)
}
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{
mustGroup("Root",
mustGroup("Internet",
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
),
mustGroup("Home Assistant",
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
),
),
},
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
t.Fatalf("unexpected first entry: %#v", got[0])
}
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
t.Fatalf("unexpected second entry: %#v", got[1])
}
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel()
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
entry.Tags = "automation; home"
entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"},
Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
},
{
ID: "entry-2",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.Search("git")
got := loaded.Search("vault")
if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
}
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "entry-1-old",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "Original version",
},
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
}
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
if loaded.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
}
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
},
},
},
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := model.Search("git")
got := model.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
}
}
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
},
},
},
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
},
},
},
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.Search("git")
got := loaded.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
}
}
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Current credential",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Original credential",
Path: []string{"Root", "Internet"},
},
+89 -5
View File
@@ -96,6 +96,27 @@ func (m Model) EntriesInPath(path []string) []Entry {
return entries
}
func (m Model) EntriesUnderPath(path []string) []Entry {
var entries []Entry
for _, entry := range m.Entries {
if !hasPathPrefix(entry.Path, path) {
continue
}
entries = append(entries, entry)
}
slices.SortFunc(entries, func(a, b Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return entries
}
func (m Model) Search(query string) []SearchResult {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
@@ -256,13 +277,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
}
func (m *Model) CreateGroup(parent []string, name string) {
groupPath := append(append([]string(nil), parent...), name)
for _, existing := range m.Groups {
if slices.Equal(existing, groupPath) {
return
groupPath := append([]string(nil), parent...)
for _, part := range splitGroupPath(name) {
groupPath = append(groupPath, part)
if groupPathExists(m.Groups, groupPath) {
continue
}
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
}
m.Groups = append(m.Groups, groupPath)
}
func (m *Model) RenameGroup(path []string, newName string) error {
@@ -310,6 +332,47 @@ func (m *Model) MoveEntry(id string, path []string) error {
return ErrEntryNotFound
}
func (m *Model) MoveGroup(path, parent []string) error {
if len(path) == 0 {
return ErrEntryNotFound
}
if hasPathPrefix(parent, path) {
return ErrEntryNotFound
}
groupName := path[len(path)-1]
newPath := append(append([]string(nil), parent...), groupName)
moved := false
for i := range m.Entries {
if !hasPathPrefix(m.Entries[i].Path, path) {
continue
}
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
moved = true
}
for i := range m.Templates {
if !hasPathPrefix(m.Templates[i].Path, path) {
continue
}
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
moved = true
}
for i := range m.Groups {
if !hasPathPrefix(m.Groups[i], path) {
continue
}
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
moved = true
}
if !moved {
return ErrEntryNotFound
}
if !groupPathExists(m.Groups, newPath) {
m.Groups = append(m.Groups, append([]string(nil), newPath...))
}
return nil
}
func (m *Model) MoveTemplate(id string, path []string) error {
for i := range m.Templates {
if m.Templates[i].ID != id {
@@ -349,6 +412,27 @@ func hasPathPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix)
}
func splitGroupPath(name string) []string {
var parts []string
for _, part := range strings.Split(name, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
parts = append(parts, part)
}
return parts
}
func groupPathExists(groups [][]string, path []string) bool {
for _, existing := range groups {
if slices.Equal(existing, path) {
return true
}
}
return false
}
func mergeEntryTemplate(template, overrides Entry) Entry {
entry := cloneEntry(template)
+87 -36
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model {
return Model{
Entries: []Entry{
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
},
}
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel()
got := model.ChildGroups([]string{"Joe"})
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
@@ -31,30 +31,43 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel()
got := model.EntriesInPath([]string{"Joe", "Internet"})
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
}
}
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Crew)) = %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)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel()
got := model.Search("git")
got := model.Search("vault")
if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got))
}
if got[0].Entry.Title != "Git Server" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
}
if got[0].Path != "Joe / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
}
}
@@ -106,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://www.dynadot.com",
Path: []string{"Joe", "Internet"},
URL: "https://bellagio.example.invalid",
Path: []string{"Crew", "Internet"},
Tags: []string{"dns"},
})
if err != nil {
@@ -121,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
}
if entry.Title != "Dynadot" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
}
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
t.Fatalf("entry credentials = %#v, want override values", entry)
}
@@ -141,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
}
got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
}
}
@@ -165,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{
Entries: []Entry{
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -209,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
@@ -225,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
}
if duplicate.Title != "Git Server (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
}
got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
@@ -237,29 +250,45 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance")
model.CreateGroup([]string{"Crew"}, "Finance")
got := model.ChildGroups([]string{"Joe"})
got := model.ChildGroups([]string{"Crew"})
want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
}
}
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
}
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel()
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Joe", "Infra"})
got := model.EntriesInPath([]string{"Crew", "Infra"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
}
}
@@ -276,15 +305,37 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
}
}
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 {
t.Fatalf("MoveGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Tricia", "Internet"})
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")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
}
}
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err)
}
got := model.ChildGroups([]string{"Joe"})
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
+100
View File
@@ -0,0 +1,100 @@
package vault
import (
"fmt"
"slices"
"github.com/tobischo/gokeepasslib/v3"
)
type SecuritySettings struct {
Cipher string
KDF string
}
const (
CipherAES256 = "aes256"
CipherChaCha20 = "chacha20"
KDFAES = "aes-kdf"
KDFArgon2 = "argon2"
)
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
return []string{CipherAES256, CipherChaCha20}, []string{KDFAES, KDFArgon2}
}
func DetectSecuritySettings(config *KDBXConfig) SecuritySettings {
settings := SecuritySettings{
Cipher: CipherChaCha20,
KDF: KDFArgon2,
}
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
return settings
}
if slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
settings.Cipher = CipherAES256
}
if config.Header.FileHeaders.KdfParameters != nil && slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
settings.KDF = KDFAES
}
return settings
}
func NewSecurityConfig(settings SecuritySettings) (*KDBXConfig, error) {
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
config := &KDBXConfig{
Header: cloneHeader(db.Header),
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
}
return ApplySecuritySettings(config, settings)
}
func ApplySecuritySettings(config *KDBXConfig, settings SecuritySettings) (*KDBXConfig, error) {
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
return NewSecurityConfig(settings)
}
out := &KDBXConfig{
Header: cloneHeader(config.Header),
InnerHeader: cloneInnerHeader(config.InnerHeader),
}
if out.Header.FileHeaders.KdfParameters == nil {
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
out.Header.FileHeaders.KdfParameters = cloneHeader(defaults.Header).FileHeaders.KdfParameters
}
switch settings.Cipher {
case "", CipherChaCha20:
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherChaCha20)
out.Header.FileHeaders.EncryptionIV = randomBytes(12)
case CipherAES256:
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherAES)
out.Header.FileHeaders.EncryptionIV = randomBytes(16)
default:
return nil, fmt.Errorf("unsupported cipher %q", settings.Cipher)
}
var salt [32]byte
copy(salt[:], randomBytes(32))
switch settings.KDF {
case "", KDFArgon2:
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
kdf := defaults.Header.FileHeaders.KdfParameters
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
UUID: slices.Clone(gokeepasslib.KdfArgon2),
Rounds: kdf.Rounds,
Salt: salt,
Parallelism: kdf.Parallelism,
Memory: kdf.Memory,
Iterations: kdf.Iterations,
Version: kdf.Version,
}
case KDFAES:
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
UUID: slices.Clone(gokeepasslib.KdfAES4),
Rounds: 6000,
Salt: salt,
}
default:
return nil, fmt.Errorf("unsupported KDF %q", settings.KDF)
}
return out, nil
}
+50
View File
@@ -0,0 +1,50 @@
package vault
import (
"bytes"
"slices"
"testing"
"github.com/tobischo/gokeepasslib/v3"
)
func TestNewSecurityConfigCreatesRequestedCipherAndKDF(t *testing.T) {
t.Parallel()
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
if err != nil {
t.Fatalf("NewSecurityConfig() error = %v", err)
}
if !slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
t.Fatalf("CipherID = %x, want %x", config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES)
}
if !slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
t.Fatalf("KDF UUID = %x, want %x", config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4)
}
}
func TestApplySecuritySettingsPreservesRequestedChoicesAcrossSave(t *testing.T) {
t.Parallel()
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherChaCha20, KDF: KDFArgon2})
if err != nil {
t.Fatalf("NewSecurityConfig() error = %v", err)
}
config, err = ApplySecuritySettings(config, SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
if err != nil {
t.Fatalf("ApplySecuritySettings() error = %v", err)
}
var encoded bytes.Buffer
if err := SaveKDBXWithConfigAndKey(&encoded, Model{}, MasterKey{Password: "correct horse battery staple"}, config); err != nil {
t.Fatalf("SaveKDBXWithConfigAndKey() error = %v", err)
}
_, reloadedConfig, err := LoadKDBXWithConfig(bytes.NewReader(encoded.Bytes()), MasterKey{Password: "correct horse battery staple"})
if err != nil {
t.Fatalf("LoadKDBXWithConfig() error = %v", err)
}
got := DetectSecuritySettings(reloadedConfig)
if got.Cipher != CipherAES256 || got.KDF != KDFAES {
t.Fatalf("DetectSecuritySettings() = %#v, want aes256/aes-kdf", got)
}
}
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
}
w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}