Compare commits

..

119 Commits

Author SHA1 Message Date
Joe Julian d8db9f14b3 Align audit UI tests with empty-state model 2026-04-01 17:21:16 -07:00
Joe Julian d1485a66c1 Refine audit UI and vault settings placement 2026-04-01 17:21:16 -07:00
Joe Julian cb1447e554 Improve local vault open usability 2026-04-01 17:17:37 -07:00
Joe Julian 315988134b Refine entry detail and edit panels 2026-04-01 17:15:58 -07:00
Joe Julian 6293eca072 Tighten mobile locked vault hierarchy 2026-04-01 17:15:20 -07:00
Joe Julian 3510bcc3b6 Improve autofill status feedback 2026-04-01 17:13:43 -07:00
Joe Julian 5c56abf31b Refine API token management UI 2026-04-01 17:12:29 -07:00
Joe Julian 139ea12317 Improve entries navigation readability 2026-04-01 17:12:17 -07:00
Joe Julian fddf864c42 Refine remote open lifecycle flow 2026-04-01 17:10:26 -07:00
Joe Julian 6eee43c1df Clarify group navigation and management 2026-04-01 17:10:04 -07:00
Joe Julian 18d57a2f8e Refine recycle bin and sync UX 2026-04-01 17:09:51 -07:00
Joe Julian b4bcceab31 Clarify lifecycle loading and target selection 2026-04-01 17:00:24 -07:00
Joe Julian 85f07ded39 Avoid blocking UI during vault open and unlock 2026-04-01 16:54:16 -07:00
Joe Julian 2fded8a403 Improve recent targets and workflow context 2026-04-01 16:43:15 -07:00
Joe Julian 23853c479a Refine group, editor, and admin workflows 2026-04-01 16:34:24 -07:00
Joe Julian 54ec195e61 Clarify start, locked, and recycle flows 2026-04-01 16:29:53 -07:00
Joe Julian 1675811aa3 Tighten navigation and admin UI 2026-04-01 16:24:11 -07:00
Joe Julian 14f4b31cf6 Refine settings and empty-state affordances 2026-04-01 15:00:46 -07:00
Joe Julian 1148eb819f Simplify lifecycle and section UI 2026-04-01 14:58:56 -07:00
Joe Julian 1ec373821d Add UI review follow-ups to TODO 2026-04-01 14:22:53 -07:00
Joe Julian 3e03d4669a Honor alternate autofill targets from entry fields 2026-04-01 10:36:08 -07:00
Joe Julian b9ecd0ae70 Fix entry and group navigation workflows 2026-04-01 10:24:57 -07:00
Joe Julian 69aa72ee3b Support Android app targets in autofill service 2026-04-01 05:56:52 -07:00
Joe Julian fbd364f01d Disambiguate same-host Android fill matches 2026-04-01 05:21:15 -07:00
Joe Julian 8be0aad14d Add Android accessibility-based Chrome form fill 2026-04-01 01:24:28 -07:00
Joe Julian eef209ae43 Add Android autofill service packaging 2026-03-31 23:13:10 -07:00
Joe Julian 322dc84917 Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian 3d53ff8e95 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian 0a6b7fe5c0 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 82bfd694b6 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian d9c2d48ff5 Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian 8495beb8dc Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 3e471366ce Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian f9b58367da Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 20f84ac58e Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 2db8a71704 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b7a4742ee6 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 9afddd7a93 Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian 84c188129e Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 980d30f6c2 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e6b33134c9 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian a269217c60 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian 722d2eefa0 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian dba0bf1f2c Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 47942014d7 Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian ed2118223f Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian 9e30f654b2 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 1ecad1fae9 Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian fc34508689 Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 9dea7a4a34 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian e5904dd224 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian da769a9728 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian f47682f3a1 Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 9dcc5f1d6f Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian ee9b00bd85 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 8ce924e08f Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian bda28eef4b Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian bf56e38bc5 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian d137018d3a Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian fcc0291dd9 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian cc127a013e Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian b2f1d9a66d Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian b391cea295 Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian fba135ff09 Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 71b383114d Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian 75dc6453f7 Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 72b413a11a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 75002a4c47 Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian e98256709d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 5e15ad3265 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian d492743eb1 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian 4a629c16bd Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 62fc343ecf Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 1e51eff76e Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 4509634a15 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian 47bbbdb05e Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian 4fbaee3970 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian d2b49ca0ad Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian fedeab2fc1 Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian b56401b5c6 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 01559a3a2b Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian 16dc1de3c8 Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian bd9674a1f5 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian e2e38a97a0 Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian 34a6e0150e Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian ec063ac81f Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian ac5b6894cf Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian d6bc213474 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian 706a72e135 test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian f8111f626f test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian 0bfa30de91 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 24dcf9c8ad Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian ea5c8b3c31 Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian c355eebed5 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 7651b3b237 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian da942b41d7 Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 1c0cd20bcc Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian cffe05af82 Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian 8deccced9e Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4fe912b41f Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian 55ca6352b4 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian 0cfccb58d6 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian b043ecdc83 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian d3be07f252 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 0f30c5b1c5 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 985150c36f Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian f39bdcd3be Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 48ffa78fa2 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 1349fe6e38 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 19ccab1d8d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian ae1921506d Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian 1f51904567 Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian ef867adfa1 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian 5eb2068d3e Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian 77407e0510 Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian dd0b0b6f6e Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 566256ab79 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian a4b3d5c1e1 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian ce01c47256 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
162 changed files with 17752 additions and 31118 deletions
-116
View File
@@ -1,116 +0,0 @@
---
name: keepassgo-apk-test
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
---
# KeePassGO APK Test
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
## Use This Skill When
- Building `build/keepassgo.apk`.
- Installing or launching KeePassGO in the Android emulator.
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
- Checking whether a Gio or Android toolchain change broke runtime behavior.
## Known Working Environment
- Preferred emulator AVD: `KeepassGoAPI35`
- Package: `org.julianfamily.keepassgo`
- Activity: `org.gioui.GioActivity`
- Local APK build defaults:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-ndk`
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
- CI APK build uses:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
## Known Regression
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
- `gioui.org v0.8.0` rendered correctly in the same environment.
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
## Safety Rules
- Do not clobber the users real KeePassGO state while testing.
- For host-side validation, use isolated state such as:
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
- Prefer sanitized demo or test vaults when seeding emulator tests.
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the users existing recent-vault state.
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
2. Prefer the `KeepassGoAPI35` AVD.
3. Install with `adb install -r build/keepassgo.apk`.
4. Launch with:
```sh
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
```
5. Confirm focus before drawing conclusions:
```sh
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
```
6. Capture evidence before editing code:
screenshot
focused window
relevant `logcat`
## Validation Checklist
- APK builds successfully with `make apk`.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
## Issue-Specific Checks
### Black Screen
- Confirm the app is focused.
- Capture a screenshot and `logcat` before changing anything.
- Compare the current Gio version against the known `v0.9.0` regression history.
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
### Clipboard
- Verify behavior in the emulator, not just unit tests.
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
### File Picker Or Local Sync
- Verify the picker flow on Android itself.
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
## Report Back
When closing out work, state:
- the exact build command used
- whether the emulator session was reused or newly started
- whether install succeeded
- whether the app actually rendered
- what was verified manually in the emulator
- what remains unverified
-109
View File
@@ -1,109 +0,0 @@
---
name: keepassgo-ship-it
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
---
# KeePassGO Ship It
Use this skill only in the KeePassGO repository. This is not a global shorthand.
Use it together with:
- `android-emulator-debug` for emulator and `adb` mechanics
- `keepass-credentials` for Nextcloud credentials
- `public-repo-sanitization` before the commit/push step
## Meaning Of `ship it`
When the user says `ship it`, do all of this unless they narrow the scope:
1. Commit the relevant KeePassGO source changes first.
2. Build and install the Arch package from that committed source.
3. Build the Android APK from that same committed source.
4. Zip the APK.
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
6. Install the rebuilt APK in the emulator.
7. Launch the rebuilt app in the emulator.
8. Open a controlled demo vault in the emulator.
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
## Required Sequence
### 1. Commit First
- Make sure the worktree state intended for shipping is committed before building.
- If the repo is dirty in unrelated ways, commit only the relevant changes.
- Before the commit or push, run the public-repo sanitization checks.
### 2. Build And Install The Arch Package
From the repo root:
```sh
make archlinux-pkgbuild
cd packaging/archlinux/keepassgo-git
makepkg -si --noconfirm
```
The installed package version must correspond to the committed source, not a dirty worktree.
### 3. Build The APK
Use the repo's known-good local JDK unless the environment already proves otherwise:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory.
- Use a name that includes the commit, for example:
`keepassgo-<shortsha>-apk.zip`
### 5. Upload To Nextcloud
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
- Ensure that directory exists with mode `700`.
- Create secret temp files with mode `600`.
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
### 6. Emulator Install And Launch
- Reuse the existing emulator session if one is already running.
- Install with replacement:
```sh
adb install -r build/keepassgo.apk
```
- Launch KeePassGO and confirm it is focused.
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
### 7. Open A Controlled Demo Vault
- Do not rely on the user's real vault for this step.
- Use a controlled/sanitized demo vault that you can unlock yourself.
- Open it in the emulator before closing out `ship it`.
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
## Closeout Requirements
When reporting back after `ship it`, include:
- the commit that was shipped
- the installed Arch package version
- the APK path
- the uploaded ZIP URL
- confirmation that the emulator app was launched
- confirmation that the controlled demo vault was opened
## Constraints
- Keep this workflow specific to KeePassGO.
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
- Do not use `rm -rf`.
- Do not use `rm -f`.
-179
View File
@@ -1,179 +0,0 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
lint-test:
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Lint
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
- name: Test
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
build:
needs: lint-test
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Prepare dist directory
shell: bash
run: |
set -euo pipefail
mkdir -p "${DIST_DIR}"
- name: Build desktop binaries
shell: bash
run: |
set -euo pipefail
app_version="$(git describe --tags --always --dirty)"
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
# Keep the CI matrix to targets this runner can build reproducibly.
for target in \
"linux amd64" \
"windows amd64" \
"windows arm64"
do
set -- ${target}
goos="$1"
goarch="$2"
ext=""
cgo_enabled=0
if [[ "${goos}" == "linux" ]]; then
cgo_enabled=1
fi
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
signkey_path="$(mktemp)"
trap 'rm -f -- "$signkey_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
NODE_OPTIONS: --use-system-ca
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
with:
name: keepassgo-${{ gitea.sha }}
path: |
dist/keepassgo-linux-amd64
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
retention-days: 30
- name: Publish release artifacts
if: startsWith(gitea.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
shell: bash
run: |
set -euo pipefail
python3 scripts/gitea_release.py \
--server "${GITEA_SERVER_URL}" \
--repo "${GITEA_REPOSITORY}" \
--token "${GITEA_TOKEN}" \
--tag "${GITEA_REF_NAME}" \
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
-8
View File
@@ -1,10 +1,2 @@
build/ build/
*.apk *.apk
/keepassgo
/keepassgo-browser-bridge
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
packaging/archlinux/keepassgo-git/pkg/
packaging/archlinux/keepassgo-git/src/
packaging/archlinux/keepassgo-git/keepassgo/
-11
View File
@@ -1,19 +1,8 @@
linters: linters:
enable: enable:
- errcheck - errcheck
- gocyclo
- gosimple - gosimple
- govet - govet
- ineffassign - ineffassign
- staticcheck - staticcheck
- unused - unused
linters-settings:
gocyclo:
min-complexity: 15
issues:
exclude-rules:
- path: _test\.go
linters:
- gocyclo
-64
View File
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
## Skills ## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work. - Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are: - The available Go skills for this repository are:
`go-code-review`, `go-code-review`,
`go-concurrency`, `go-concurrency`,
@@ -95,20 +93,6 @@ These features are product requirements, not “nice to have” ideas.
- Phone should optimize for low tap count, not purity of mobile patterns. - Phone should optimize for low tap count, not purity of mobile patterns.
- The stacked phone layout is the current preferred phone direction. - The stacked phone layout is the current preferred phone direction.
- Do not reintroduce the abandoned phone flow mode unless explicitly requested. - Do not reintroduce the abandoned phone flow mode unless explicitly requested.
- Keep the product feeling like the same application on desktop, Android phone,
and Android tablet.
- Platform adaptation is allowed for layout and spacing, not for changing the
user's mental model of the workflow.
- Use the same action names, the same primary next steps, and the same workflow
order across platforms unless there is a hard platform constraint.
- Treat workflow prominence and reachability as product behavior, not visual
polish. A feature is not parity-complete if it technically exists but is
harder to discover or reach on one platform.
- Prefer shared workflow decisions with platform-specific presentation, rather
than platform-specific workflow branches.
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
vaults, and locations from heist movies so test fixtures stay obviously fake
and consistent with the product theme.
## Architecture ## Architecture
@@ -118,24 +102,9 @@ These features are product requirements, not “nice to have” ideas.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details. - Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state. - Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols. - Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
- Treat the vault model as local-first across all platforms:
every usable vault is a local KDBX file first, and remote sync attaches to
that local vault rather than replacing it as the primary object.
- Keep the remote-sync model standardized across desktop, Android phone, and
Android tablet:
shared remote configuration belongs in the vault,
cross-platform workflow stays the same,
and only Android's initial KDBX share/import transport may differ.
- Do not persist remote credentials in plaintext app-local state.
Keep only non-secret binding metadata outside the vault.
- When working on remote-sync behavior, preserve the local cache / local-first
design:
opening or creating the local vault is the main workflow,
and remote setup, remote settings, and remote use attach to that vault.
## Delivery Discipline ## Delivery Discipline
- Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied. - Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices: - Continue iterating in test-first slices:
@@ -143,14 +112,6 @@ These features are product requirements, not “nice to have” ideas.
implement the minimum code to satisfy them, implement the minimum code to satisfy them,
verify with `go test ./...` and relevant lint checks, verify with `go test ./...` and relevant lint checks,
and commit each completed behavior. and commit each completed behavior.
- For cross-platform UI work, behavior tests must cover workflow parity, not
just feature or label parity.
- For lifecycle, open, unlock, sync, and other primary flows, tests should
assert the same conceptual next step across desktop, phone, and tablet
layouts.
- When Android or phone UX is part of the slice, verify real reachability on an
emulator or device for the exact flow being changed. Do not count “the same
buttons exist somewhere on screen” as sufficient validation.
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo. - Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
- If blocked, state the blocker concretely and stop only at that point. - If blocked, state the blocker concretely and stop only at that point.
@@ -159,39 +120,14 @@ These features are product requirements, not “nice to have” ideas.
- Plan for direct KDBX support. - Plan for direct KDBX support.
- Plan for direct WebDAV-based workflows. - Plan for direct WebDAV-based workflows.
- Avoid adding npm-based or browser-stack dependencies. - Avoid adding npm-based or browser-stack dependencies.
- Keep remote configuration and synchronization local-first:
the app should maintain a live local KDBX cache even when using a remote
store, so remote outage does not eliminate vault access.
- Prefer vault-backed remote setup and lookup over ad hoc local credential
storage.
- On Android, use system picker/share mechanisms for local vault import/export
rather than raw path entry when a user is selecting or sharing a vault file.
## Tooling ## Tooling
- Keep `golangci-lint` passing. - Keep `golangci-lint` passing.
- Keep `go test ./...` passing. - Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- For the Arch package, treat
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
as the source of truth and regenerate `PKGBUILD` from the template before
building.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext. - If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
- Do not commit generated binaries. - Do not commit generated binaries.
+2 -3
View File
@@ -12,7 +12,6 @@ Environment:
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`. - `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`. - `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id. - `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path. - `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version. - `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK. - `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
@@ -29,12 +28,12 @@ Installed machine prerequisites expected by this repo:
The repo tracks `gogio` as a Go tool, so the build runs through: The repo tracks `gogio` as a Go tool, so the build runs through:
```sh ```sh
go tool gogio -target android ./cmd/keepassgo ... go tool gogio -target android ...
``` ```
The Android build uses the branded icon asset at: The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png` - `assets/keepassgo-icon.png`
Note: Note:
+3 -38
View File
@@ -5,27 +5,10 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APP_ID ?= org.julianfamily.keepassgo APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1 APK_VERSION ?= 0.1.0.1
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
ANDROID_MIN_SDK ?= 28 ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35 ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
ARCH_REPO_DIR ?= $(CURDIR)
GOGIO_SIGN_FLAGS := .PHONY: apk
ifneq ($(strip $(SIGNKEY)),)
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
endif
ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate
apk: android/keepassgo-android.jar apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@@ -41,14 +24,12 @@ apk: android/keepassgo-android.jar
go tool gogio -target android \ go tool gogio -target android \
-buildmode exe \ -buildmode exe \
-appid $(APP_ID) \ -appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \
$(GOGIO_SIGN_FLAGS) \
-o $(APK_OUT) \ -o $(APK_OUT) \
-version $(APK_VERSION) \ -version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \ -minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \ -targetsdk $(ANDROID_TARGET_SDK) \
-icon internal/assets/keepassgo-icon.png \ -icon assets/keepassgo-icon.png \
./cmd/keepassgo .
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@@ -61,19 +42,3 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
-d "$$tmpdir" \ -d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \ $$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .' "$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
@mkdir -p "$(ARCH_PKG_DIR)"
@sed \
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
browser-bridge:
go build ./cmd/keepassgo-browser-bridge
browser-extension-validate:
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
+2 -36
View File
@@ -38,35 +38,9 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
Desktop build: Desktop build:
```bash ```bash
go build ./cmd/keepassgo go build ./...
``` ```
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
You can override the version shown in KeePassGO with:
```bash
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- `/usr/bin/keepassgo-browser-bridge`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging ## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`. KeePassGO uses Gio, so Android packaging is done with `gogio`.
@@ -90,7 +64,7 @@ go get -tool gioui.org/cmd/gogio@latest
Package: Package:
```bash ```bash
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo 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. You will need the Android SDK and NDK installed and configured for real device or release packaging.
@@ -99,11 +73,3 @@ You will need the Android SDK and NDK installed and configured for real device o
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type. Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md). See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
## Browser Extension
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
See [`docs/browser-extension.md`](./docs/browser-extension.md).
+241 -36
View File
@@ -11,41 +11,10 @@ The product is not complete until the global exit criteria at the end of this fi
These items came from a hands-on emulator and desktop walkthrough. These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish. They should be treated as usability work, not just polish.
### Cross-Platform Workflow Parity
These items are required to keep desktop, Android phone, and Android tablet
feeling like the same application rather than three related UIs.
- Workflow parity:
define canonical workflows for open, unlock, set up remote sync, use remote
sync, browse entries, and edit entries.
- Workflow parity:
ensure desktop, phone, and tablet use the same action names and the same
primary next steps for those workflows.
- Workflow parity:
remove or reduce platform-specific workflow exceptions where the same user
intent currently takes a different route on different form factors.
- Testing:
add cross-mode behavior tests that assert workflow order and action
prominence, not just label presence.
- Testing:
add explicit lifecycle/open-screen tests for reachability of the primary
action on desktop, phone, and tablet layouts.
- Testing:
add explicit remote-sync workflow tests that prove setup, settings, use, and
removal are reachable from the same primary affordance family across modes.
- Android verification:
validate changed lifecycle/open/sync workflows on the emulator or a device,
including with the on-screen keyboard visible.
- Android verification:
treat “present but below the fold or behind an unexpected branch” as a parity
failure, not as acceptable platform variation.
### Primary Workflow Changes ### Primary Workflow Changes
These should remain in the main user flow rather than being hidden behind a settings gear. These should remain in the main user flow rather than being hidden behind a settings gear.
- Browser extension:
- Local open flow: - Local open flow:
make the start screen primarily about opening a vault, not configuring one. make the start screen primarily about opening a vault, not configuring one.
- Local open flow: - Local open flow:
@@ -98,10 +67,6 @@ These should remain in the main user flow rather than being hidden behind a sett
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer. keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize: - Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral. avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Synchronize:
define exact local-versus-remote merge semantics for cases where both sides changed, and make the user-facing action names describe the real behavior instead of ambiguous `push`/`pull` labels if those actions perform two-way reconciliation.
- Synchronize:
choose sync wording and defaults that maximize user comprehension and safety, especially around merge, overwrite, conflict, and retry behavior.
- Phone layout: - Phone layout:
continue reducing header and control density so content appears sooner. continue reducing header and control density so content appears sooner.
- Mobile reliability: - Mobile reliability:
@@ -137,6 +102,195 @@ These are important, but they should likely move behind a dedicated settings gea
- Phone and desktop layouts both present a clear information hierarchy. - Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations. - The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
They are designed to be independently landable wherever file overlap permits.
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
### API Segment A: Token Domain Model
Scope:
- Represent API tokens as first-class vault-backed records.
- Mark token entries explicitly as API credentials rather than generic passwords.
- Store token metadata:
token id,
hashed secret or verifier,
display name,
client name,
created at,
expires at,
disabled state.
- Keep the persisted representation compatible with KDBX entry fields.
Exit criteria:
- A domain type exists for API tokens and round-trips through the persisted vault model.
- Generic entry listing can distinguish API token entries from ordinary secrets.
- Tests cover create, load, save, and parse behavior for API token entries.
- `go test ./...` passes.
### API Segment B: Token Issuance And Rotation
Scope:
- Generate new API tokens for external tools.
- Return the cleartext token only at creation or explicit rotation time.
- Rotate an existing token while preserving its identity and policy linkage.
- Revoke or disable a token without deleting policy history.
Exit criteria:
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
- Cleartext token material is only exposed on creation or rotation paths.
- Tests cover generation, rotation, and disable/revoke semantics.
- `go test ./...` passes.
### API Segment C: Token Expiration
Scope:
- Allow tokens to have optional expiration timestamps.
- Treat expired tokens as unauthenticated.
- Surface expiration in UI and gRPC management views.
- Support non-expiring tokens explicitly.
Exit criteria:
- Expired tokens are rejected by the gRPC authentication path.
- Token expiration can be created, edited, and removed through the service layer.
- Tests cover valid, expired, and non-expiring token behavior.
- `go test ./...` passes.
### API Segment D: Authorization Policy Model
Scope:
- Define an authorization model for token-scoped access.
- Support allow and deny rules over:
folders/groups,
specific entries,
entry fields where needed,
and operation types.
- Keep specific deny rules higher priority than broad allow rules.
- Model “not yet decided” separately from “denied”.
Exit criteria:
- A policy evaluator exists for token, resource, and operation tuples.
- Explicit deny overrides allow.
- Unspecified access is distinguishable from denied access.
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
- `go test ./...` passes.
### API Segment E: gRPC Authentication And Authorization Enforcement
Scope:
- Replace the current single static bearer-token interceptor with token-backed auth.
- Authenticate callers using issued KeePassGO API tokens.
- Authorize every gRPC method against token policy.
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
Exit criteria:
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
- Unauthorized requests return the correct authz/authn gRPC status.
- Integration tests cover permitted, denied, expired, and revoked token behavior.
- `go test ./...` passes.
### API Segment F: Approval Queue And Pending Access Requests
Scope:
- When a token requests access to a resource that is neither explicitly allowed nor denied:
create a pending approval request.
- Include:
token identity,
client name,
requested operation,
requested group/entry scope,
requested time,
and permanence choice.
- Allow the request to be accepted, denied, or canceled by the user.
Exit criteria:
- Unspecified access creates a pending approval instead of silently denying or allowing.
- Pending approvals are queryable from the application layer.
- Canceling the prompt results in the API request failing without granting access.
- Tests cover pending creation, approval, denial, and cancellation.
- `go test ./...` passes.
### API Segment G: Approval UI
Scope:
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
- Provide actions:
allow once,
deny once,
allow permanently,
deny permanently,
cancel.
- Make the requested scope and operation clear to the user.
- Ensure the dialog appears only for requests not already decided.
Exit criteria:
- A pending request triggers a visible approval surface in the app.
- The user can allow, deny, or cancel from the UI.
- Permanent decisions become persisted policy rules.
- UI tests cover each approval outcome.
- `go test ./...` passes.
### API Segment H: gRPC Request Blocking And Resume Behavior
Scope:
- Define how an in-flight gRPC call waits for or fails on user approval.
- Hold the request while approval is pending within a bounded timeout.
- Return unauthenticated or permission-denied when denied/canceled/expired.
- Resume the original call automatically when approval is granted.
Exit criteria:
- Pending requests block safely without leaking goroutines.
- Allowed requests resume and complete without the client reissuing the call where practical.
- Denied and canceled requests return a consistent gRPC status code and message.
- Tests cover timeout, allow, deny, and cancel paths.
- `go test ./...` passes.
### API Segment I: Token Management UI
Scope:
- Add UI for listing API tokens.
- Create token flow with one-time secret display.
- Edit token display metadata and expiration.
- Disable, revoke, and rotate tokens.
- Show effective policy summary per token.
Exit criteria:
- Users can manage API tokens from the app UI end to end.
- One-time token display is explicit and not re-shown later.
- Expiration and disable state are visible.
- UI tests cover create, rotate, disable, revoke, and edit flows.
- `go test ./...` passes.
### API Segment J: Policy Management UI
Scope:
- Let users define folder, entry, and operation scopes for each token.
- Show explicit allow and deny rules.
- Show inherited implications of a folder-level rule.
- Let users review prior permanent decisions created from approval prompts.
Exit criteria:
- Users can inspect and edit token policy from the UI.
- Folder-level and entry-level rules are distinguishable and editable.
- Permanent prompt decisions are visible as policy.
- UI tests cover rule creation, update, and deletion.
- `go test ./...` passes.
### API Segment K: Audit And Event History
Scope:
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
- Record authorization failures and expirations without logging secret material.
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
Exit criteria:
- Security-relevant API token events are captured without secret leakage.
- Approval outcomes and policy changes are auditable.
- Tests cover audit generation for the main token lifecycle and approval actions.
- `go test ./...` passes.
### Segment 1: Application State Ownership ### Segment 1: Application State Ownership
Scope: Scope:
@@ -205,6 +359,19 @@ Exit criteria:
- Validation and visible error states exist for missing or invalid key material. - Validation and visible error states exist for missing or invalid key material.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 5: KDBX Security Settings Preservation
Scope:
- Preserve supported cipher and KDF settings when reopening and saving.
- Surface relevant settings in product-facing docs or UI where appropriate.
- Document unsupported settings explicitly.
Exit criteria:
- Reopen-and-save cycles preserve supported KDBX security settings.
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
- Tests cover settings preservation across save cycles.
- `go test ./...` passes.
### Segment 6: Entry CRUD UI ### Segment 6: Entry CRUD UI
Scope: Scope:
@@ -410,6 +577,33 @@ Exit criteria:
- UI tests or controller-integrated tests cover these states. - UI tests or controller-integrated tests cover these states.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 18: Desktop Automation Resolution
Scope:
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
- or explicitly finalize the design that secure gRPC supersedes auto-type.
- Keep the decision documented in-repo.
Exit criteria:
- The desktop automation requirement is explicitly resolved in code or docs.
- The chosen approach is documented in `docs/desktop-automation.md`.
- Any implemented behavior is tested.
- `go test ./...` passes.
### Segment 19: Packaging And Runbook
Scope:
- Keep the app runnable from source.
- Document desktop build and run steps.
- Document Android packaging with `gogio`.
- Add icon and metadata placeholders if missing.
Exit criteria:
- `README.md` is accurate for local build, run, and Android packaging guidance.
- Placeholder metadata exists where needed for packaging.
- The app still builds from the repo.
- `go test ./...` passes.
### Segment 20: Regression And Integration Coverage ### Segment 20: Regression And Integration Coverage
Scope: Scope:
@@ -427,10 +621,11 @@ Exit criteria:
Do not treat the product as complete until all of the following are true: Do not treat the product as complete until all of the following are true:
- All remaining numbered segments, API segments, and UI review follow-ups are complete. - Segment 1 through Segment 20 are all complete.
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI. - KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling. - KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
- KeePassGO supports master password, key file, and composite key workflows in the product. - KeePassGO supports master password, key file, and composite key workflows in the product.
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation. - KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI. - KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI. - KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
@@ -440,7 +635,17 @@ Do not treat the product as complete until all of the following are true:
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end. - KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
- KeePassGO exposes password generation profiles through both UI and gRPC. - KeePassGO exposes password generation profiles through both UI and gRPC.
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration. - The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
- The desktop automation requirement is explicitly resolved.
- Keyboard-first navigation and common shortcuts exist for major product workflows. - Keyboard-first navigation and common shortcuts exist for major product workflows.
- The UI no longer depends on prototype-only mock behavior for any core workflow. - The UI no longer depends on prototype-only mock behavior for any core workflow.
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes. - `go test ./...` passes.
- `go tool golangci-lint run ./...` passes. - `go tool golangci-lint run ./...` passes.
## Remaining Gaps Against AGENTS.md
None currently identified.
The last explicitly tracked gaps are now closed:
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
-23
View File
@@ -22,26 +22,3 @@
android:name="android.accessibilityservice" android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" /> android:resource="@xml/keepassgo_accessibility_service" />
</service> </service>
<provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share"
android:exported="false"
android:grantUriPermissions="true" />
<activity
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter>
</activity>
Binary file not shown.
@@ -1,81 +0,0 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public final class AndroidShare {
private static final String DEFAULT_TITLE = "KeePassGO Vault";
private AndroidShare() {
}
public static void shareVault(Context context, String path, String title) throws IOException {
File source = new File(path);
if (!source.isFile()) {
throw new IOException("vault file not found: " + path);
}
File shared = copyToSharedExport(context, source);
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
Intent send = new Intent(Intent.ACTION_SEND);
send.setType("application/x-keepass2");
send.putExtra(Intent.EXTRA_STREAM, uri);
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
Intent chooser = Intent.createChooser(send, "Share vault");
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
context.startActivity(chooser);
}
static File sharedDirectory(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
}
private static File copyToSharedExport(Context context, File source) throws IOException {
File dir = sharedDirectory(context);
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File target = new File(dir, sanitizeFilename(source.getName()));
try (FileInputStream in = new FileInputStream(source);
FileOutputStream out = new FileOutputStream(target, false)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) >= 0) {
out.write(buffer, 0, count);
}
}
return target;
}
private static String sanitizeFilename(String name) {
String trimmed = name == null ? "" : name.trim();
if (trimmed.isEmpty()) {
return "shared-vault.kdbx";
}
if (trimmed.endsWith(".kdbx")) {
return trimmed;
}
return trimmed + ".kdbx";
}
private static String sanitizeTitle(String title, String fallbackName) {
String trimmed = title == null ? "" : title.trim();
if (!trimmed.isEmpty()) {
return trimmed;
}
String fallback = fallbackName == null ? "" : fallbackName.trim();
if (!fallback.isEmpty()) {
return fallback;
}
return DEFAULT_TITLE;
}
}
@@ -1,131 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
handleIntent(getIntent());
launchMainActivity();
finish();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
launchMainActivity();
finish();
}
private void handleIntent(Intent intent) {
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
return;
}
try {
persistPendingImport(uri);
Log.i(TAG, "queued shared vault import from " + uri);
} catch (IOException | RuntimeException err) {
Log.e(TAG, "failed to queue shared vault import", err);
}
}
private Uri resolveSharedUri(Intent intent) {
if (intent == null) {
return null;
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
}
if (Intent.ACTION_VIEW.equals(action)) {
return intent.getData();
}
return null;
}
private void persistPendingImport(Uri uri) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
try (InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
}
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) >= 0) {
out.write(buffer, 0, count);
}
}
}
File nameFile = new File(dir, "pending-shared-vault-name.txt");
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) {
return displayName;
}
String lastSegment = uri.getLastPathSegment();
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
return lastSegment.trim();
}
return DEFAULT_NAME;
}
private String queryDisplayName(Uri uri) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (index >= 0) {
String value = cursor.getString(index);
if (value != null) {
return value.trim();
}
}
}
} catch (RuntimeException err) {
Log.w(TAG, "failed to query display name", err);
} finally {
if (cursor != null) {
cursor.close();
}
}
return "";
}
private void launchMainActivity() {
Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity");
startActivity(launch);
}
}
@@ -1,100 +0,0 @@
package org.julianfamily.keepassgo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileNotFoundException;
public final class SharedVaultProvider extends ContentProvider {
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
File file = resolveSharedFile(uri);
String[] columns = projection;
if (columns == null || columns.length == 0) {
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
}
MatrixCursor cursor = new MatrixCursor(columns, 1);
Object[] row = new Object[columns.length];
for (int i = 0; i < columns.length; i++) {
switch (columns[i]) {
case OpenableColumns.DISPLAY_NAME:
row[i] = file.getName();
break;
case OpenableColumns.SIZE:
row[i] = file.length();
break;
default:
row[i] = null;
break;
}
}
cursor.addRow(row);
return cursor;
}
@Override
public String getType(Uri uri) {
return "application/x-keepass2";
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("insert is not supported");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("delete is not supported");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("update is not supported");
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
File file = resolveSharedFile(uri);
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
static Uri uriForFile(String name) {
return new Uri.Builder()
.scheme("content")
.authority(AUTHORITY)
.appendPath(name)
.build();
}
private File resolveSharedFile(Uri uri) {
if (getContext() == null) {
throw new IllegalStateException("provider context is unavailable");
}
String name = sanitizeFilename(uri.getLastPathSegment());
return new File(AndroidShare.sharedDirectory(getContext()), name);
}
private static String sanitizeFilename(String name) {
if (name == null) {
return "shared-vault.kdbx";
}
String trimmed = name.trim();
if (trimmed.isEmpty()) {
return "shared-vault.kdbx";
}
return new File(trimmed).getName();
}
}
+17 -67
View File
@@ -4,33 +4,29 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
type DirtyProvider func() bool type DirtyProvider func() bool
type Host struct { type Host struct {
server *Server server *Server
grpcServer *grpc.Server grpcServer *grpc.Server
listener net.Listener listener net.Listener
lifecycle lifecycleBackend lifecycle lifecycleBackend
dirty DirtyProvider dirty DirtyProvider
mu sync.Mutex mu sync.Mutex
lastModel vault.Model lastModel vault.Model
started bool started bool
listenAddr string listenAddr string
socketPath string
} }
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) { func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
@@ -39,17 +35,13 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
return nil, nil return nil, nil
} }
network, endpoint, err := grpcaddr.Parse(addr) listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
listener, socketPath, err := listen(network, endpoint)
if err != nil { if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err) return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
} }
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle) service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer() server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service) keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{ host := &Host{
@@ -58,8 +50,7 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
listener: listener, listener: listener,
lifecycle: lifecycle, lifecycle: lifecycle,
dirty: dirty, dirty: dirty,
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath), listenAddr: listener.Addr().String(),
socketPath: socketPath,
started: true, started: true,
} }
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) { if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
@@ -100,16 +91,7 @@ func (h *Host) Stop() error {
} }
h.started = false h.started = false
h.grpcServer.Stop() h.grpcServer.Stop()
err := h.listener.Close() return h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr
}
}
return err
} }
func (h *Host) SyncFromLifecycle() error { func (h *Host) SyncFromLifecycle() error {
@@ -138,35 +120,3 @@ func (h *Host) SyncFromLifecycle() error {
h.server.SetSessionState(h.lastModel, locked, dirty) h.server.SetSessionState(h.lastModel, locked, dirty)
return nil return nil
} }
func listen(network, endpoint string) (net.Listener, string, error) {
if network == "unix" {
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
return nil, "", err
}
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
listener, err := net.Listen("unix", endpoint)
if err != nil {
return nil, "", err
}
if err := os.Chmod(endpoint, 0o600); err != nil {
_ = listener.Close()
return nil, "", err
}
return listener, endpoint, nil
}
listener, err := net.Listen(network, endpoint)
if err != nil {
return nil, "", err
}
return listener, "", nil
}
func formatListenAddress(network, listenerAddr, socketPath string) string {
if network == "unix" {
return "unix://" + socketPath
}
return listenerAddr
}
+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: "Git Server", 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")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,34 +9,33 @@ import (
"sync" "sync"
"time" "time"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/apitokens"
) )
var ( var (
ErrRequestDenied = errors.New("authorization request denied") ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled") ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out") ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found") ErrRequestNotFound = errors.New("authorization request not found")
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
) )
type Outcome string type Outcome string
const ( const (
OutcomeAllowOnce Outcome = "allow-once" OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once" OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent" OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent" OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel" OutcomeCancel Outcome = "cancel"
) )
type Request struct { type Request struct {
ID string ID string
TokenID string TokenID string
TokenName string TokenName string
ClientName string ClientName string
Operation apitokens.Operation Operation apitokens.Operation
Resource apitokens.Resource Resource apitokens.Resource
RequestedAt time.Time RequestedAt time.Time
} }
@@ -51,7 +50,6 @@ type Broker struct {
timeout time.Duration timeout time.Duration
now func() time.Time now func() time.Time
nextID func() string nextID func() string
notify func()
} }
type pendingRequest struct { type pendingRequest struct {
@@ -110,18 +108,9 @@ func (b *Broker) Pending() []Request {
return requests return requests
} }
func (b *Broker) SetChangeNotifier(notify func()) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.notify = notify
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) { func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil { if b == nil {
return Result{}, ErrBrokerNotConfigured return Result{}, ErrRequestTimedOut
} }
pending := &pendingRequest{ pending := &pendingRequest{
@@ -139,20 +128,12 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
b.mu.Lock() b.mu.Lock()
b.pending[pending.request.ID] = pending b.pending[pending.request.ID] = pending
notify := b.notify
b.mu.Unlock() b.mu.Unlock()
if notify != nil {
notify()
}
defer func() { defer func() {
b.mu.Lock() b.mu.Lock()
delete(b.pending, pending.request.ID) delete(b.pending, pending.request.ID)
notify := b.notify
b.mu.Unlock() b.mu.Unlock()
if notify != nil {
notify()
}
}() }()
timer := time.NewTimer(b.timeout) timer := time.NewTimer(b.timeout)
@@ -3,11 +3,10 @@ package apiapproval
import ( import (
"context" "context"
"errors" "errors"
"slices"
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/apitokens"
) )
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) { func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
@@ -121,46 +120,6 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
} }
} }
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
t.Parallel()
var broker *Broker
_, 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, ErrBrokerNotConfigured) {
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
}
}
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
changes := make(chan int, 4)
broker.SetChangeNotifier(func() {
changes <- len(broker.Pending())
})
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, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
got := []int{<-changes, <-changes}
slices.Sort(got)
if !slices.Equal(got, []int{0, 1}) {
t.Fatalf("change notifications = %v, want [0 1]", got)
}
}
func waitForPending(t *testing.T, broker *Broker, want int) { func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper() t.Helper()
@@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/apitokens"
) )
type EventType string type EventType string
@@ -16,12 +16,6 @@ const (
EventApprovalDenied EventType = "approval_denied" EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled" EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out" EventApprovalTimedOut EventType = "approval_timed_out"
EventTokenIssued EventType = "token_issued"
EventTokenUpdated EventType = "token_updated"
EventTokenRotated EventType = "token_rotated"
EventTokenDisabled EventType = "token_disabled"
EventTokenRevoked EventType = "token_revoked"
EventTokenDeleted EventType = "token_deleted"
EventAutofillFound EventType = "autofill_found" EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous" EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked" EventAutofillBlocked EventType = "autofill_blocked"
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/apitokens"
) )
func TestLogKeepsNewestEventsWithinBound(t *testing.T) { func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
@@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
const ( const (
@@ -55,18 +55,15 @@ const (
DecisionDeny Decision = "deny" DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt" DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries" OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups" OperationListGroups Operation = "list_groups"
OperationListTemplates Operation = "list_templates" OperationReadEntry Operation = "read_entry"
OperationReadEntry Operation = "read_entry" OperationCopyPassword Operation = "copy_password"
OperationCopyPassword Operation = "copy_password" OperationCopyUsername Operation = "copy_username"
OperationCopyUsername Operation = "copy_username" OperationCopyURL Operation = "copy_url"
OperationCopyURL Operation = "copy_url" OperationMutateEntry Operation = "mutate_entry"
OperationMutateEntry Operation = "mutate_entry" OperationMutateGroup Operation = "mutate_group"
OperationMutateGroup Operation = "mutate_group" OperationManageVault Operation = "manage_vault"
OperationMutateTemplate Operation = "mutate_template"
OperationGeneratePassword Operation = "generate_password"
OperationManageVault Operation = "manage_vault"
) )
type Resource struct { type Resource struct {
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) { func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
+160 -524
View File
@@ -7,12 +7,10 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav"
) )
type Section string type Section string
@@ -28,12 +26,8 @@ const (
SectionRecycleBin Section = "recycle-bin" SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens" SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit" SectionAPIAudit Section = "api-audit"
SectionAbout Section = "about"
) )
const entriesRootLabel = "Root"
const templatesRootLabel = "Templates"
type CurrentSession interface { type CurrentSession interface {
Current() (vault.Model, error) Current() (vault.Model, error)
} }
@@ -59,15 +53,6 @@ type SaveableSession interface {
Save() error Save() error
} }
type AutoSaveableSession interface {
SaveableSession
HasSaveTarget() bool
}
type RemoteAwareSession interface {
IsRemote() bool
}
type SynchronizableSession interface { type SynchronizableSession interface {
CurrentSession CurrentSession
Synchronize() error Synchronize() error
@@ -76,7 +61,6 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface { type AdvancedSynchronizableSession interface {
CurrentSession CurrentSession
SynchronizeFromLocal(string) error SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error
@@ -102,10 +86,6 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface { type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings SecuritySettings() vault.SecuritySettings
@@ -119,8 +99,6 @@ type ApprovalManager interface {
type State struct { type State struct {
Session CurrentSession Session CurrentSession
Approvals ApprovalManager Approvals ApprovalManager
AuditLog *apiaudit.Log
AutoSaveRemote bool
Section Section Section Section
CurrentPath []string CurrentPath []string
SearchQuery string SearchQuery string
@@ -153,176 +131,116 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
return apitokens.Entries(model) return apitokens.Entries(model)
} }
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
profiles := slices.Clone(model.RemoteProfiles)
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
case a.ID < b.ID:
return -1
case a.ID > b.ID:
return 1
default:
return 0
}
})
return profiles, nil
}
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
entries := slices.Clone(model.Entries)
slices.SortFunc(entries, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
case a.ID < b.ID:
return -1
case a.ID > b.ID:
return 1
default:
return 0
}
})
return entries, nil
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
return result.token, result.secret, nil
}
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil {
return apitokens.Token{}, "", err
}
return result.token, result.secret, nil
}
func (s *State) UpsertAPIToken(token apitokens.Token) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DisableAPIToken(id string) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Disable(token)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) RevokeAPIToken(id string, when time.Time) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
token = apitokens.Revoke(token, when)
apitokens.Upsert(model, token)
return tokenMutationResult{token: token}, nil
})
return err
}
func (s *State) DeleteAPIToken(id string) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
if err := apitokens.Delete(model, id); err != nil {
return tokenMutationResult{}, err
}
return tokenMutationResult{token: token}, nil
})
return err
}
type tokenMutationResult struct {
token apitokens.Token
secret string
}
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
return tokenMutationResult{}, fmt.Errorf("session is not mutable") return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
} }
model, err := session.Current() model, err := session.Current()
if err != nil { if err != nil {
return tokenMutationResult{}, err return apitokens.Token{}, "", err
} }
result, err := mutate(&model) token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil { if err != nil {
return tokenMutationResult{}, err return apitokens.Token{}, "", err
} }
apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil { s.Dirty = true
return tokenMutationResult{}, err return token, secret, nil
}
s.recordTokenAudit(eventType, result.token, message)
return result, nil
} }
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) { func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
if s.AuditLog == nil { session, ok := s.Session.(MutableSession)
return if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
} }
s.AuditLog.Record(apiaudit.Event{ model, err := session.Current()
Type: eventType, if err != nil {
TokenID: token.ID, return apitokens.Token{}, "", err
TokenName: token.Name, }
ClientName: token.ClientName, token, err := apitokens.Find(model, id)
Resource: apitokens.Resource{ if err != nil {
Kind: apitokens.ResourceEntry, return apitokens.Token{}, "", err
Path: apitokens.EntryPath, }
EntryID: token.ID, token, secret, err := apitokens.Rotate(token, now)
}, if err != nil {
Message: message, 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) { func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
@@ -341,7 +259,8 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
if err := security.ConfigureSecurity(settings); err != nil { if err := security.ConfigureSecurity(settings); err != nil {
return err return err
} }
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) ShowSection(section Section) { func (s *State) ShowSection(section Section) {
@@ -383,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil return model.EntriesUnderPath(s.CurrentPath), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -403,13 +322,13 @@ func (s *State) ChildGroups() ([]string, error) {
} }
if s.Section != SectionEntries { if s.Section != SectionEntries {
if s.Section == SectionTemplates { if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
} }
return childGroups(s.entriesForSection(model), s.CurrentPath), nil return childGroups(s.entriesForSection(model), s.CurrentPath), nil
} }
return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil return model.ChildGroups(s.CurrentPath), nil
} }
func (s *State) SelectVisibleIndex(index int) error { func (s *State) SelectVisibleIndex(index int) error {
@@ -453,13 +372,13 @@ func (s *State) currentModel() (vault.Model, error) {
func (s *State) entriesForSection(model vault.Model) []vault.Entry { func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil)) return slices.Clone(model.Templates)
case SectionRecycleBin: case SectionRecycleBin:
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil)) return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit, SectionAbout: case SectionAPITokens, SectionAPIAudit:
return nil return nil
default: default:
return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil)) return slices.Clone(model.Entries)
} }
} }
@@ -467,11 +386,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path) path := slices.Clone(entry.Path)
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
path = logicalTemplatePath(path) if len(path) == 0 || path[0] != "Templates" {
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin: case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...) path = append([]string{"Recycle Bin"}, path...)
case SectionEntries:
path = logicalEntriesPath(path)
} }
return strings.Join(path, " / ") return strings.Join(path, " / ")
} }
@@ -528,163 +447,6 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out return out
} }
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalTemplatePath(path []string) []string {
if len(path) == 0 {
return []string{templatesRootLabel}
}
if path[0] == templatesRootLabel {
return append([]string(nil), path...)
}
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
}
func templatesViewPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == templatesRootLabel {
return append([]string(nil), path[1:]...)
}
return append([]string(nil), path...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalTemplatePath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalTemplateEntry(entry.History[i])
}
return entry
}
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalTemplateEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func templateEntryForModel(entry vault.Entry) vault.Entry {
entry.Path = templatesViewPath(entry.Path)
for i := range entry.History {
entry.History[i] = templateEntryForModel(entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string { func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
var groups []string var groups []string
@@ -709,33 +471,6 @@ func childGroups(entries []vault.Entry, path []string) []string {
return groups return groups
} }
func sectionGroupView(model vault.Model, section Section) vaultview.View {
switch section {
case SectionTemplates:
return vaultview.VaultTemplates(model)
default:
return vaultview.VaultRoot(model)
}
}
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return templatesViewPath(path)
default:
return entriesViewPathForModel(model, path)
}
}
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return logicalTemplatePath(path)
default:
return logicalEntriesPathForModel(model, path)
}
}
func (s *State) DeleteSelectedEntry() error { func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -753,7 +488,8 @@ func (s *State) DeleteSelectedEntry() error {
session.Replace(model) session.Replace(model)
s.SelectedEntryID = "" s.SelectedEntryID = ""
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) RestoreEntry(id string) error { func (s *State) RestoreEntry(id string) error {
@@ -772,7 +508,8 @@ func (s *State) RestoreEntry(id string) error {
} }
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) UpsertEntry(entry vault.Entry) error { func (s *State) UpsertEntry(entry vault.Entry) error {
@@ -786,10 +523,11 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err return err
} }
model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry))) model.UpsertEntry(entry)
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) UpsertTemplate(entry vault.Entry) error { func (s *State) UpsertTemplate(entry vault.Entry) error {
@@ -803,10 +541,11 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
return err return err
} }
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry))) model.UpsertTemplate(entry)
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) { func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
@@ -820,17 +559,15 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err return vault.Entry{}, err
} }
entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides))) entry, err := model.InstantiateTemplate(templateID, overrides)
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
if err := s.markDirtyAndAutoSave(); err != nil { s.Dirty = true
return vault.Entry{}, err return entry, nil
}
return logicalEntry(entry), nil
} }
func (s *State) DeleteTemplate(id string) error { func (s *State) DeleteTemplate(id string) error {
@@ -852,7 +589,8 @@ func (s *State) DeleteTemplate(id string) error {
if s.SelectedEntryID == id { if s.SelectedEntryID == id {
s.SelectedEntryID = "" s.SelectedEntryID = ""
} }
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) { func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
@@ -873,9 +611,7 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
session.Replace(model) session.Replace(model)
s.SelectedEntryID = duplicate.ID s.SelectedEntryID = duplicate.ID
if err := s.markDirtyAndAutoSave(); err != nil { s.Dirty = true
return vault.Entry{}, err
}
return duplicate, nil return duplicate, nil
} }
@@ -895,7 +631,8 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
} }
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) Lock() error { func (s *State) Lock() error {
@@ -918,13 +655,7 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable") return fmt.Errorf("session is not lockable")
} }
if err := session.Unlock(key); err != nil { return session.Unlock(key)
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
} }
func (s *State) ChangeMasterKey(key vault.MasterKey) error { func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -937,7 +668,8 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
return err return err
} }
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) EnterGroup(name string) { func (s *State) EnterGroup(name string) {
@@ -964,25 +696,6 @@ func (s *State) Save() error {
return nil return nil
} }
func (s *State) markDirtyAndAutoSave() error {
s.Dirty = true
session, ok := s.Session.(SaveableSession)
if !ok {
return nil
}
if autosave, ok := s.Session.(AutoSaveableSession); ok && !autosave.HasSaveTarget() {
return nil
}
if remote, ok := s.Session.(RemoteAwareSession); ok && remote.IsRemote() && !s.AutoSaveRemote {
return nil
}
if err := session.Save(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) Synchronize() error { func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession) session, ok := s.Session.(SynchronizableSession)
if !ok { if !ok {
@@ -1009,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil return nil
} }
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error { func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession) session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok { if !ok {
@@ -1086,9 +787,6 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -1119,73 +817,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
model, err := s.currentModel()
if err != nil {
return err
}
resolved, err := binding.Resolve(model)
if err != nil {
return err
}
client := webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
}
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return RemoteBinding{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return RemoteBinding{}, err
}
binding, err := ConfigureRemoteBinding(&model, input)
if err != nil {
return RemoteBinding{}, err
}
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return RemoteBinding{}, err
}
return binding, nil
}
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := RemoveRemoteBinding(&model, binding); err != nil {
return err
}
session.Replace(model)
return s.markDirtyAndAutoSave()
}
func (s *State) CreateGroup(name string) error { func (s *State) CreateGroup(name string) error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -1197,10 +831,10 @@ func (s *State) CreateGroup(name string) error {
return err return err
} }
view := sectionGroupView(model, s.Section) model.CreateGroup(s.CurrentPath, name)
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) MoveCurrentGroup(parent []string) error { func (s *State) MoveCurrentGroup(parent []string) error {
@@ -1212,18 +846,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil { if err != nil {
return err return err
} }
view := sectionGroupView(model, s.Section) current := append([]string(nil), s.CurrentPath...)
current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath) if err := model.MoveGroup(current, parent); err != nil {
currentViewPath := sectionGroupViewPath(model, s.Section, current)
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
if len(currentViewPath) > 0 { if len(current) > 0 {
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1])) s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
} }
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) RenameCurrentGroup(newName string) error { func (s *State) RenameCurrentGroup(newName string) error {
@@ -1237,8 +869,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err return err
} }
view := sectionGroupView(model, s.Section) if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
return err return err
} }
@@ -1246,7 +877,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
if len(s.CurrentPath) > 0 { if len(s.CurrentPath) > 0 {
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName) s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
} }
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) MoveSelectedEntry(path []string) error { func (s *State) MoveSelectedEntry(path []string) error {
@@ -1260,12 +892,13 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err return err
} }
if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil { if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) DeleteCurrentGroup() error { func (s *State) DeleteCurrentGroup() error {
@@ -1279,8 +912,7 @@ func (s *State) DeleteCurrentGroup() error {
return err return err
} }
view := sectionGroupView(model, s.Section) if err := model.DeleteGroup(s.CurrentPath); err != nil {
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
return err return err
} }
@@ -1289,7 +921,8 @@ func (s *State) DeleteCurrentGroup() error {
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...) s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
} }
s.SelectedEntryID = "" s.SelectedEntryID = ""
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error { func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
@@ -1315,7 +948,8 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
} }
model.Entries[i].Attachments[name] = append([]byte(nil), content...) model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
return vault.ErrEntryNotFound return vault.ErrEntryNotFound
@@ -1341,7 +975,8 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
} }
model.Entries[i].Attachments[name] = append([]byte(nil), content...) model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
return vault.ErrEntryNotFound return vault.ErrEntryNotFound
@@ -1370,7 +1005,8 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
model.Entries[i].Attachments = nil model.Entries[i].Attachments = nil
} }
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() s.Dirty = true
return nil
} }
return vault.ErrEntryNotFound return vault.ErrEntryNotFound
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
type Entry struct { type Entry struct {
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
func TestBuildFiltersAndNormalizesEntries(t *testing.T) { func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe", Username: "joe",
Password: "secret", Password: "secret",
URL: "https://10.0.2.2:8443/login", URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"}, Path: []string{"Joe", "Internet"},
}, },
{ {
ID: "two", ID: "two",
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host", Title: "Bare Host",
Username: "user", Username: "user",
Password: "pass", Password: "pass",
URL: "surveillance.crew.example.invalid", URL: "lights.julianfamily.org",
Fields: map[string]string{ Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile", "AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account", "KP2A_URL_1": "https://lights.julianfamily.org/account",
}, },
}, },
}, },
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
if got.Entries[0].Host != "10.0.2.2" { if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host) t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
} }
if got.Entries[1].Host != "surveillance.crew.example.invalid" { if got.Entries[1].Host != "lights.julianfamily.org" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host) t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
} }
if len(got.Entries[1].Targets) != 3 { if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets)) t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
Title: "Host A", Title: "Host A",
Username: "first", Username: "first",
Password: "secret1", Password: "secret1",
URL: "https://surveillance.crew.example.invalid/", URL: "https://lights.julianfamily.org/",
Host: "surveillance.crew.example.invalid", Host: "lights.julianfamily.org",
}, },
{ {
ID: "two", ID: "two",
Title: "Host B", Title: "Host B",
Username: "second", Username: "second",
Password: "secret2", Password: "secret2",
URL: "https://surveillance.crew.example.invalid/", URL: "https://lights.julianfamily.org/",
Host: "surveillance.crew.example.invalid", Host: "lights.julianfamily.org",
}, },
}, },
} }
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok { if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host") t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
} }
} }
-27
View File
@@ -1,27 +0,0 @@
# KeePassGO Browser Extension
Shared extension assets for Firefox and Chromium-based browsers live here.
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
- `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
- `options.html` stores the API token in browser extension storage
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
Quick extension-side checks:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
Reproducible Chromium validation:
```bash
make browser-extension-validate
```
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
-744
View File
@@ -1,744 +0,0 @@
const ext = globalThis.browser ?? globalThis.chrome;
const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = {
bearerToken: ""
};
const pageStatePrefix = "keepassgo-page-state:";
const matchCacheTTL = 30 * 1000;
const pendingPollMillis = 1500;
const pageStates = new Map();
const refreshJobs = new Map();
const pendingPollers = new Map();
function storageGet(keys) {
if (usePromiseAPI) {
return ext.storage.local.get(keys);
}
return new Promise((resolve, reject) => {
ext.storage.local.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value);
});
});
}
function storageSet(value) {
if (usePromiseAPI) {
return ext.storage.local.set(value);
}
return new Promise((resolve, reject) => {
ext.storage.local.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionArea() {
return ext.storage?.session ?? null;
}
function sessionStorageGet(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve({});
}
if (usePromiseAPI) {
return area.get(keys).then((value) => value || {});
}
return new Promise((resolve, reject) => {
area.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value || {});
});
});
}
function sessionStorageSet(value) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.set(value);
}
return new Promise((resolve, reject) => {
area.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionStorageRemove(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.remove(keys);
}
return new Promise((resolve, reject) => {
area.remove(keys, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function tabsQuery(query) {
if (usePromiseAPI) {
return ext.tabs.query(query);
}
return new Promise((resolve, reject) => {
ext.tabs.query(query, (tabs) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tabs);
});
});
}
function tabsGet(tabId) {
if (usePromiseAPI) {
return ext.tabs.get(tabId);
}
return new Promise((resolve, reject) => {
ext.tabs.get(tabId, (tab) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tab);
});
});
}
function tabsSendMessage(tabId, message) {
if (usePromiseAPI) {
return ext.tabs.sendMessage(tabId, message);
}
return new Promise((resolve, reject) => {
ext.tabs.sendMessage(tabId, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function connectNative(message) {
if (usePromiseAPI) {
return ext.runtime.sendNativeMessage(nativeHost, message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const stored = await storageGet(["bearerToken"]);
return {
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
};
}
function supportsPageStateURL(rawURL) {
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
}
function pageStateKey(tabId) {
return `${pageStatePrefix}${String(tabId)}`;
}
function cloneTarget(target) {
return target && typeof target === "object" ? { ...target } : null;
}
function normalizePageState(state) {
return {
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
configured: Boolean(state?.configured),
success: state?.success !== false,
status: state?.status ?? null,
matches: Array.isArray(state?.matches) ? state.matches : [],
error: typeof state?.error === "string" ? state.error : "",
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
signature: typeof state?.signature === "string" ? state.signature : "",
focusTarget: cloneTarget(state?.focusTarget),
pendingFill: Boolean(state?.pendingFill),
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
pendingTarget: cloneTarget(state?.pendingTarget),
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
};
}
function defaultPageState(tabId, pageUrl) {
return normalizePageState({
tabId,
pageUrl,
configured: true,
success: true,
status: null,
matches: [],
error: "",
pageHasLoginForm: false,
signature: "",
focusTarget: null,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
lastFilledEntryId: "",
updatedAt: 0
});
}
async function getPageState(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const existing = pageStates.get(tabId);
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
return normalizePageState(existing);
}
const stored = await sessionStorageGet(pageStateKey(tabId));
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
if (pageUrl && state.pageUrl !== pageUrl) {
return defaultPageState(tabId, pageUrl);
}
pageStates.set(tabId, state);
return state;
}
async function setPageState(tabId, nextState) {
const state = normalizePageState({ ...nextState, tabId });
if (!Number.isInteger(tabId)) {
return state;
}
pageStates.set(tabId, state);
await sessionStorageSet({ [pageStateKey(tabId)]: state });
await updateActionState(tabId, state);
await notifyContentState(tabId, state);
return state;
}
function clearPendingPoll(tabId) {
const timer = pendingPollers.get(tabId);
if (timer !== undefined) {
clearTimeout(timer);
pendingPollers.delete(tabId);
}
}
async function clearPageState(tabId) {
if (!Number.isInteger(tabId)) {
return;
}
pageStates.delete(tabId);
refreshJobs.delete(tabId);
clearPendingPoll(tabId);
await sessionStorageRemove(pageStateKey(tabId));
await clearActionState(tabId);
}
function describeError(error) {
return error instanceof Error ? error.message : String(error || "Unknown error");
}
function approvalHintForState(state) {
if (!state.pendingFill) {
return "";
}
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
}
function schedulePendingPoll(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return;
}
clearPendingPoll(tabId);
const timer = setTimeout(() => {
pendingPollers.delete(tabId);
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
}, pendingPollMillis);
pendingPollers.set(tabId, timer);
}
async function notifyContentState(tabId, state) {
if (!Number.isInteger(tabId)) {
return;
}
try {
await tabsSendMessage(tabId, {
type: "keepassgo-page-state",
state
});
} catch (_error) {
// Ignore pages without a ready content script.
}
}
async function clearActionState(tabId) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: "" }),
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
]);
}
function actionPresentationForState(state) {
let badgeText = "";
let title = "KeePassGO Browser";
let color = "#255f4a";
if (state.pendingFill) {
badgeText = "!";
color = "#9f5f0e";
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
} else if (!state.configured) {
title = "Configure KeePassGO Browser in extension settings";
} else if (!state.success) {
badgeText = "!";
color = "#9f2f2f";
title = state.error || "KeePassGO is unavailable for this page";
} else if (state.status?.locked) {
title = "Unlock KeePassGO to fill this page";
} else if (state.pageHasLoginForm && state.matches.length > 0) {
badgeText = String(Math.min(state.matches.length, 9));
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
} else if (state.pageHasLoginForm) {
title = "KeePassGO found no matching entries on this page";
}
return { badgeText, title, color };
}
async function updateActionState(tabId, state) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
const presentation = actionPresentationForState(state);
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
ext.action.setTitle({ tabId, title: presentation.title })
]);
}
async function activePageContext() {
const [tab] = await tabsQuery({ active: true, currentWindow: true });
return {
tabId: Number.isInteger(tab?.id) ? tab.id : null,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
async function scanTabForLoginForm(tabId) {
if (!Number.isInteger(tabId)) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
try {
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
return {
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
focusTarget: cloneTarget(response?.focusTarget),
signature: typeof response?.signature === "string" ? response.signature : ""
};
} catch (_error) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
}
function shouldReuseMatches(state, force) {
if (force || state.pendingFill) {
return false;
}
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
return false;
}
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
}
function tokenPendingApprovalCount(status) {
return Number(status?.tokenPendingApprovalCount || 0);
}
async function fetchStatus(settings) {
if (!settings.bearerToken) {
return {
success: false,
configured: false,
status: null,
error: "Set an API token in extension settings."
};
}
const status = await connectNative({
action: "status",
bearerToken: settings.bearerToken
});
return {
success: Boolean(status?.success),
configured: true,
status: status?.status ?? null,
error: status?.error ?? ""
};
}
async function refreshPageState(tabId, pageUrl, options = {}) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const force = Boolean(options.force);
const existingJob = refreshJobs.get(tabId);
if (existingJob && !force) {
return existingJob;
}
const job = (async () => {
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
if (!supportsPageStateURL(resolvedURL)) {
const tab = await tabsGet(tabId).catch(() => null);
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
}
if (!supportsPageStateURL(resolvedURL)) {
await clearPageState(tabId);
return defaultPageState(tabId, resolvedURL || "");
}
let state = await getPageState(tabId, resolvedURL);
if (state.pageUrl !== resolvedURL) {
state = defaultPageState(tabId, resolvedURL);
}
const scan = typeof options.pageHasLoginForm === "boolean"
? {
pageHasLoginForm: options.pageHasLoginForm,
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
signature: typeof options.signature === "string" ? options.signature : state.signature
}
: await scanTabForLoginForm(tabId);
state = {
...state,
pageUrl: resolvedURL,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
signature: typeof scan.signature === "string" ? scan.signature : state.signature
};
const settings = await loadSettings();
const statusInfo = await fetchStatus(settings).catch((error) => ({
success: false,
configured: true,
status: null,
error: describeError(error)
}));
state = {
...state,
configured: statusInfo.configured,
success: statusInfo.success,
status: statusInfo.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
error: statusInfo.error
};
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
state.matches = [];
state.updatedAt = Date.now();
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
if (shouldReuseMatches(state, force)) {
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
const matches = await connectNative({
action: "find-logins",
bearerToken: settings.bearerToken,
url: resolvedURL
});
state = {
...state,
success: Boolean(matches?.success),
status: matches?.status ?? state.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
matches: Array.isArray(matches?.matches) ? matches.matches : [],
error: matches?.error ?? "",
updatedAt: Date.now()
};
const saved = await setPageState(tabId, state);
if (saved.pendingFill) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
})().finally(() => {
if (refreshJobs.get(tabId) === job) {
refreshJobs.delete(tabId);
}
});
refreshJobs.set(tabId, job);
return job;
}
async function statusForPage(options = {}) {
let page = await activePageContext();
if (Number.isInteger(options.tabId)) {
const tab = await tabsGet(options.tabId).catch(() => null);
page = {
tabId: options.tabId,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
if (!options.force) {
const cached = await getPageState(page.tabId, page.url);
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
return cached;
}
}
return refreshPageState(page.tabId, page.url, options);
}
async function fillLogin(tabId, entryId) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
const tab = await tabsGet(tabId);
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled.");
}
let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, {
...state,
pageUrl,
pendingFill: true,
pendingEntryId: String(entryId || "").trim(),
pendingTarget: cloneTarget(state.focusTarget),
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
error: "",
updatedAt: Date.now()
});
schedulePendingPoll(tabId, pageUrl);
try {
const settings = await loadSettings();
if (!settings.bearerToken) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential.");
}
const fillResponse = await tabsSendMessage(tabId, {
type: "keepassgo-fill-credential",
credential: response.credential,
target: state.pendingTarget
});
if (!fillResponse?.ok) {
throw new Error(fillResponse?.error || "The current page could not be filled.");
}
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
lastFilledEntryId: String(entryId || "").trim(),
error: "",
updatedAt: Date.now()
});
clearPendingPoll(tabId);
return {
credential: response.credential,
pageUrl,
state
};
} catch (error) {
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
error: describeError(error),
updatedAt: Date.now()
});
clearPendingPoll(tabId);
throw error;
}
}
async function refreshActivePage(options = {}) {
const page = await activePageContext();
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
return refreshPageState(page.tabId, page.url, options);
}
const backgroundTestExports = {
normalizePageState,
actionPresentationForState,
shouldReuseMatches,
tokenPendingApprovalCount,
defaultSettings
};
if (isNodeTestEnv) {
module.exports = backgroundTestExports;
} else {
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
switch (message?.type) {
case "keepassgo-popup-state":
sendResponse(await statusForPage({
force: Boolean(message.force),
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
}));
return;
case "keepassgo-fill-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
if (Number.isInteger(targetTabID) && message.target) {
const targetState = await getPageState(targetTabID, "");
await setPageState(targetTabID, {
...targetState,
focusTarget: cloneTarget(message.target)
});
}
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-load-settings":
sendResponse({ success: true, settings: await loadSettings() });
return;
case "keepassgo-save-settings":
await storageSet({
bearerToken: String(message.settings?.bearerToken || "").trim()
});
await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true });
return;
case "keepassgo-page-ready":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
force: Boolean(message.force),
pageHasLoginForm: Boolean(message.pageHasLoginForm),
focusTarget: cloneTarget(message.focusTarget),
signature: typeof message.signature === "string" ? message.signature : ""
}));
return;
}
sendResponse(defaultPageState(null, ""));
return;
case "keepassgo-refresh-page-state":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
return;
}
sendResponse(defaultPageState(null, ""));
return;
default:
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
}
})().catch((error) => {
sendResponse({ success: false, error: describeError(error) });
});
return true;
});
ext.tabs?.onActivated?.addListener(({ tabId }) => {
void refreshPageState(tabId, "", { force: false }).catch(() => null);
});
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
if (typeof changeInfo.url === "string") {
void clearPageState(tabId).catch(() => null);
}
if (changeInfo.status === "complete") {
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
}
});
ext.tabs?.onRemoved?.addListener((tabId) => {
void clearPageState(tabId).catch(() => null);
});
}
-54
View File
@@ -1,54 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const background = require("./background.js");
test("normalizePageState preserves focused and pending field targets", () => {
const state = background.normalizePageState({
tabId: 7,
pageUrl: "https://vault.example.invalid/login",
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
});
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
});
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
const recentState = {
pageHasLoginForm: true,
matches: [{ id: "vault-console" }],
pendingFill: false,
updatedAt: Date.now()
};
assert.equal(background.shouldReuseMatches(recentState, false), true);
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
assert.equal(background.shouldReuseMatches(recentState, true), false);
});
test("actionPresentationForState prioritizes approval visibility", () => {
const presentation = background.actionPresentationForState({
pendingFill: true,
pendingMessage: "Approve the browser fill request in KeePassGO.",
configured: true,
success: true,
pageHasLoginForm: true,
matches: [{ id: "vault-console" }]
});
assert.equal(presentation.badgeText, "!");
assert.equal(presentation.color, "#9f5f0e");
assert.match(presentation.title, /approve/i);
});
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
assert.equal(background.tokenPendingApprovalCount({}), 0);
});
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, "");
});
-676
View File
@@ -1,676 +0,0 @@
const ext = globalThis.browser ?? globalThis.chrome;
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return ext.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendMessage(message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function isVisibleInput(input) {
if (!(input instanceof HTMLInputElement)) {
return false;
}
if (input.disabled || input.readOnly) {
return false;
}
const style = window.getComputedStyle(input);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
return input.offsetParent !== null || style.position === "fixed";
}
function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) {
case "password":
return "password";
default:
return "username";
}
}
function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") {
return "password";
}
const autocomplete = String(input?.autocomplete || "").toLowerCase();
if (autocomplete.includes("username") || autocomplete.includes("email")) {
return "username";
}
return "username";
}
function isUsernameCandidate(input) {
if (!isVisibleInput(input)) {
return false;
}
return describeFieldRole(input) === "username";
}
function isPasswordCandidate(input) {
return isVisibleInput(input) && describeFieldRole(input) === "password";
}
function dispatchFillEvents(input) {
if (typeof InputEvent === "function") {
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
} else {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function setInputValue(input, value) {
const prototype = Object.getPrototypeOf(input);
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
if (descriptor?.set) {
descriptor.set.call(input, value);
return;
}
input.value = value;
}
function visibleInputs(scope) {
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
}
function resolveFormInputs(anchorInput) {
if (anchorInput?.form instanceof HTMLFormElement) {
return visibleInputs(anchorInput.form);
}
return visibleInputs(document);
}
function firstVisiblePassword(scope) {
return visibleInputs(scope).find(isPasswordCandidate) || null;
}
function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null;
}
function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
let usernameInput = usernameInScope[0] || null;
if (passwordInput && usernameInScope.length !== 0) {
const priorSibling = usernameInScope.find((input) =>
typeof input.compareDocumentPosition === "function" &&
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
);
usernameInput = priorSibling || usernameInScope[0] || null;
}
if (!usernameInput) {
usernameInput = firstVisibleUsername(document);
}
return { usernameInput, passwordInput };
}
function buildFieldDescriptor(input, role) {
if (!(input instanceof HTMLInputElement)) {
return null;
}
const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document;
const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input);
const forms = Array.from(document.forms || []);
return {
role: normalizedRole,
formIndex: form ? forms.indexOf(form) : -1,
fieldIndex,
id: String(input.id || ""),
name: String(input.name || ""),
autocomplete: String(input.autocomplete || "").toLowerCase()
};
}
function resolveFieldDescriptor(descriptor) {
if (!descriptor || typeof descriptor !== "object") {
return null;
}
const normalizedRole = normalizeRole(descriptor.role);
const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document;
const inputs = visibleInputs(scope);
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
const candidate = inputs[descriptor.fieldIndex];
if (describeFieldRole(candidate) === normalizedRole) {
return candidate;
}
}
if (descriptor.id) {
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
return byID;
}
}
if (descriptor.name) {
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
if (byName) {
return byName;
}
}
if (normalizedRole === "password") {
return firstVisiblePassword(scope) || firstVisiblePassword(document);
}
return firstVisibleUsername(scope) || firstVisibleUsername(document);
}
function chooseFillTargets(targetDescriptor) {
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
const associated = associatedFieldsForAnchor(anchorInput);
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: associated.usernameInput,
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
anchorInput
};
}
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput
};
}
return {
usernameInput: associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
};
}
function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
const allVisible = visibleInputs(document);
const roles = allVisible
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
.map((input) => {
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
});
return {
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
usernameInput: targets.usernameInput,
passwordInput: targets.passwordInput,
anchorInput,
focusTarget,
signature: roles.join("|")
};
}
function fillCredential(credential, targetDescriptor) {
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
if (usernameInput && credential.username) {
usernameInput.focus();
setInputValue(usernameInput, credential.username);
dispatchFillEvents(usernameInput);
}
if (passwordInput && credential.password) {
passwordInput.focus();
setInputValue(passwordInput, credential.password);
dispatchFillEvents(passwordInput);
}
if (!usernameInput && !passwordInput) {
return { ok: false, error: "No fillable username or password fields were found." };
}
return { ok: true };
}
function domainLabel(rawURL) {
try {
return new URL(rawURL).host || "";
} catch (_error) {
return "";
}
}
function inlineMatchSummary(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (match.url) {
const host = domainLabel(match.url);
if (host) {
parts.push(host);
}
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
if (suppressed || !hasTarget) {
return false;
}
return Boolean(
state?.pageHasLoginForm &&
(
state?.pendingFill ||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
)
);
}
const contentTestExports = {
normalizeRole,
describeFieldRole,
buildFieldDescriptor,
resolveFieldDescriptor,
chooseFillTargets,
inlineMatchSummary,
domainLabel,
shouldShowInlineOverlay
};
if (isNodeTestEnv) {
module.exports = contentTestExports;
} else {
let pageState = {
configured: true,
success: true,
matches: [],
pageHasLoginForm: false,
pendingFill: false,
error: "",
focusTarget: null
};
let chooserOpen = false;
let inlineSuppressed = false;
let refreshTimer = null;
let lastReportedSignature = "";
let lastReportedTarget = "";
const root = document.createElement("div");
root.id = "keepassgo-inline-root";
root.setAttribute("aria-live", "polite");
const shadow = root.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
all: initial;
}
.dock {
position: fixed;
z-index: 2147483647;
display: none;
min-width: 220px;
max-width: min(340px, calc(100vw - 24px));
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
color: #214f44;
}
.dock[data-open="true"] .panel {
display: block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #c6d8cf;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #edf5f0);
color: #214f44;
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
cursor: pointer;
}
.trigger[data-tone="warning"] {
border-color: #e4d0ae;
background: linear-gradient(180deg, #fff8ed, #f9edd6);
color: #7f4b09;
}
.trigger[data-tone="error"] {
border-color: #e4bcbc;
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
color: #8c2f2f;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.meta {
color: #4d6d66;
font-size: 12px;
}
.panel {
display: none;
margin-top: 8px;
border: 1px solid #d7e3dc;
border-radius: 16px;
background: #fffdfa;
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
overflow: hidden;
}
.panel-header {
padding: 12px 14px 10px;
border-bottom: 1px solid #e6efea;
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
}
.panel-title {
font-weight: 700;
}
.panel-copy {
margin-top: 4px;
color: #4d6d66;
font-size: 12px;
}
.match-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.match {
display: grid;
gap: 3px;
width: 100%;
padding: 12px 14px;
border: 0;
border-top: 1px solid #eef4f0;
background: #fffdfa;
color: #214f44;
text-align: left;
cursor: pointer;
}
.match:hover,
.match:focus-visible {
background: #edf5f0;
outline: none;
}
.match strong {
font-size: 13px;
}
.pill {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 999px;
background: #e7f1eb;
color: #255f4a;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtle {
color: #4d6d66;
font-size: 12px;
}
.empty {
padding: 12px 14px;
color: #4d6d66;
font-size: 12px;
}
</style>
<div class="dock" data-open="false">
<button type="button" class="trigger" data-tone="ready">
<span class="brand">KeePassGO</span>
<span class="meta">Checking this form</span>
</button>
<div class="panel">
<div class="panel-header">
<div class="panel-title">KeePassGO suggestions</div>
<div class="panel-copy">Select a matching login for this field.</div>
</div>
<div class="match-list"></div>
</div>
</div>
`;
const dock = shadow.querySelector(".dock");
const trigger = shadow.querySelector(".trigger");
const meta = shadow.querySelector(".meta");
const matchList = shadow.querySelector(".match-list");
const panelCopy = shadow.querySelector(".panel-copy");
function ensureRootMounted() {
if (!root.isConnected) {
document.documentElement.appendChild(root);
}
}
function currentTarget() {
return chooseFillTargets(pageState.focusTarget).anchorInput;
}
function hideDock() {
chooserOpen = false;
dock.style.display = "none";
dock.dataset.open = "false";
}
function positionDock() {
const anchor = currentTarget();
if (!anchor || dock.style.display === "none") {
return;
}
const rect = anchor.getBoundingClientRect();
const width = Math.min(340, Math.max(220, rect.width));
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
dock.style.left = `${left}px`;
dock.style.top = `${top}px`;
dock.style.width = `${width}px`;
}
function renderMatches() {
matchList.textContent = "";
if (pageState.pendingFill) {
const pending = document.createElement("div");
pending.className = "empty";
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
matchList.appendChild(pending);
return;
}
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = pageState.error || "No matching entries were found for this page.";
matchList.appendChild(empty);
return;
}
for (const match of pageState.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match";
const title = document.createElement("strong");
title.textContent = match.title;
const summary = document.createElement("span");
summary.className = "subtle";
summary.textContent = inlineMatchSummary(match);
const quality = document.createElement("span");
quality.className = "pill";
quality.textContent = match.quality || "Candidate";
row.appendChild(title);
row.appendChild(summary);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
inlineSuppressed = true;
hideDock();
try {
await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
target: pageState.focusTarget
});
} catch (_error) {
pageState = {
...pageState,
pendingFill: false,
error: "KeePassGO could not fill this page."
};
renderInlineState();
} finally {
row.disabled = false;
}
});
matchList.appendChild(row);
}
}
function renderInlineState() {
const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
if (!shouldShow) {
hideDock();
return;
}
ensureRootMounted();
dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
if (pageState.pendingFill) {
meta.textContent = "Approval needed in KeePassGO";
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} else {
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
panelCopy.textContent = "Select a matching login for this field.";
}
dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches();
positionDock();
}
function reportFieldState(force) {
const scan = scanLoginFields();
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return;
}
lastReportedSignature = scan.signature;
lastReportedTarget = nextTarget;
void runtimeSend({
type: "keepassgo-page-ready",
force: Boolean(force),
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
}).then((response) => {
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
pageState = {
...pageState,
...response,
pageHasLoginForm: Boolean(response.pageHasLoginForm),
focusTarget: response.focusTarget || pageState.focusTarget
};
renderInlineState();
}
}).catch(() => null);
}
function scheduleRefresh(force) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = null;
reportFieldState(force);
}, force ? 0 : 120);
}
trigger.addEventListener("click", () => {
chooserOpen = !chooserOpen;
renderInlineState();
});
document.addEventListener("focusin", () => {
scheduleRefresh(false);
});
document.addEventListener("input", () => {
scheduleRefresh(false);
}, true);
document.addEventListener("click", (event) => {
if (!root.contains(event.target)) {
chooserOpen = false;
renderInlineState();
}
});
window.addEventListener("scroll", () => {
positionDock();
}, true);
window.addEventListener("resize", () => {
positionDock();
});
const observer = new MutationObserver((records) => {
if (records.some((record) => record.type === "childList")) {
scheduleRefresh(false);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-fill-credential") {
try {
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
} catch (error) {
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
return false;
}
if (message?.type === "keepassgo-page-state") {
pageState = {
...pageState,
...(message.state || {})
};
renderInlineState();
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-page-scan") {
const scan = scanLoginFields();
sendResponse({
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
});
return false;
}
return false;
});
reportFieldState(true);
}
-33
View File
@@ -1,33 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const content = require("./content.js");
test("inlineMatchSummary includes username, host, and path context", () => {
const summary = content.inlineMatchSummary({
username: "dannyocean",
url: "https://vault.example.invalid/login",
path: ["Root", "Crew"]
});
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
});
test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
assert.equal(content.domainLabel("not-a-url"), "");
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "vault-console" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
});
-26
View File
@@ -1,26 +0,0 @@
{
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
-37
View File
@@ -1,37 +0,0 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": [
"activeTab",
"nativeMessaging",
"storage",
"tabs",
"http://*/*",
"https://*/*"
],
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"browser_specific_settings": {
"gecko": {
"id": "browser@keepassgo.com"
}
}
}
-30
View File
@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser Settings</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="surface settings">
<header class="topbar">
<div>
<h1>Browser Settings</h1>
<p class="subtle">Connect the extension to KeePassGO.</p>
</div>
</header>
<form id="settings-form" class="settings-form">
<label>
<span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
</div>
<p id="settings-status" class="subtle"></p>
</form>
</main>
<script src="options.js"></script>
</body>
</html>
-49
View File
@@ -1,49 +0,0 @@
const extOptions = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extOptions.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extOptions.runtime.sendMessage(message, (response) => {
const error = extOptions.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const response = await runtimeSend({ type: "keepassgo-load-settings" });
if (!response?.success) {
throw new Error(response?.error || "Could not load settings.");
}
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
}
async function saveSettings(event) {
event.preventDefault();
const status = document.getElementById("settings-status");
status.textContent = "Saving…";
try {
const response = await runtimeSend({
type: "keepassgo-save-settings",
settings: {
bearerToken: document.getElementById("bearer-token").value
}
});
if (!response?.success) {
throw new Error(response?.error || "Could not save settings.");
}
status.textContent = "Saved.";
} catch (error) {
status.textContent = error instanceof Error ? error.message : String(error);
}
}
document.getElementById("settings-form").addEventListener("submit", saveSettings);
void loadSettings();
-30
View File
@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="popup">
<main class="surface">
<header class="topbar">
<div>
<h1>KeePassGO</h1>
<p id="page-host" class="subtle">Checking current page</p>
</div>
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
</header>
<section id="status-card" class="status-card">
<strong id="status-title">Loading</strong>
<p id="status-message" class="subtle">Checking KeePassGO.</p>
</section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>
-173
View File
@@ -1,173 +0,0 @@
const extPopup = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extPopup.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extPopup.runtime.sendMessage(message, (response) => {
const error = extPopup.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function hostFromURL(rawURL) {
try {
return new URL(rawURL).host || rawURL;
} catch (_error) {
return rawURL || "Current page";
}
}
function setStatus(title, message, tone) {
const card = document.getElementById("status-card");
card.dataset.tone = tone || "neutral";
document.getElementById("status-title").textContent = title;
document.getElementById("status-message").textContent = message;
}
function matchSubtitle(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function renderMatches(state) {
const root = document.getElementById("matches");
const targetTabID = popupTabID();
root.textContent = "";
if (!Array.isArray(state.matches) || state.matches.length === 0) {
const empty = document.createElement("p");
empty.className = "subtle";
empty.textContent = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
root.appendChild(empty);
return;
}
for (const match of state.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match-row";
const main = document.createElement("span");
main.className = "match-main";
const title = document.createElement("strong");
title.textContent = match.title;
const subtitle = document.createElement("span");
subtitle.className = "subtle";
subtitle.textContent = matchSubtitle(match);
const quality = document.createElement("span");
quality.className = "quality";
quality.textContent = match.quality || "";
main.appendChild(title);
main.appendChild(subtitle);
row.appendChild(main);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
try {
const result = await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
} catch (error) {
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
});
root.appendChild(row);
}
}
function renderPageHint(state) {
const hint = document.getElementById("page-hint");
if (state.pendingFill) {
hint.textContent = "Approval is pending in KeePassGO.";
return;
}
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
return;
}
if (state.pageHasLoginForm) {
hint.textContent = "KeePassGO checked this login form already.";
return;
}
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
}
function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) {
return null;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isInteger(parsed) ? parsed : null;
}
async function main() {
try {
const state = await runtimeSend({
type: "keepassgo-popup-state",
force: true,
tabId: popupTabID()
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
if (!state.configured) {
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
renderMatches({ matches: [] });
return;
}
if (state.pendingFill) {
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
renderMatches(state);
return;
}
if (!state.success) {
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
renderMatches(state);
return;
}
if (state.status?.locked) {
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
renderMatches(state);
return;
}
const count = Array.isArray(state.matches) ? state.matches.length : 0;
if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
} else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else {
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
}
renderMatches(state);
} catch (error) {
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
renderMatches({ matches: [] });
}
}
void main();
-178
View File
@@ -1,178 +0,0 @@
:root {
color-scheme: light;
--ink: #214f44;
--ink-soft: #4d6d66;
--surface: #fffdfa;
--surface-2: #f2f7f3;
--line: #d7e3dc;
--accent: #255f4a;
--accent-soft: #dfeee6;
--warn: #9f5f0e;
--error: #9f2f2f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
linear-gradient(180deg, #f8fbf8, #eef4f0);
}
body.popup {
min-width: 360px;
}
.surface {
padding: 16px;
}
.topbar {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
line-height: 1.1;
}
h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
color: var(--ink-soft);
}
.subtle {
color: var(--ink-soft);
}
.status-card {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--surface);
margin-bottom: 16px;
}
.status-card[data-tone="ready"] {
border-color: #c5dccf;
background: var(--accent-soft);
}
.status-card[data-tone="warning"] {
border-color: #e4d0ae;
background: #fbf4e7;
}
.status-card[data-tone="error"] {
border-color: #e4bcbc;
background: #fcf1f1;
}
.inline-hint {
margin: -6px 0 16px;
}
.match-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.match-row,
button,
.link-button {
appearance: none;
border: 0;
border-radius: 12px;
background: var(--surface);
color: var(--ink);
text-decoration: none;
}
.match-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
cursor: pointer;
}
.match-row:hover,
button:hover,
.link-button:hover {
background: var(--surface-2);
}
.match-main {
display: flex;
flex-direction: column;
align-items: start;
text-align: left;
}
.quality {
font-size: 12px;
color: var(--ink-soft);
text-transform: uppercase;
}
.settings {
max-width: 720px;
margin: 0 auto;
min-height: 100vh;
}
.settings-form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
}
input,
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--ink);
font: inherit;
}
button,
.link-button {
padding: 10px 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.actions {
display: flex;
justify-content: end;
}
+2 -6
View File
@@ -13,10 +13,9 @@ const (
DefaultAppID = "org.julianfamily.keepassgo" DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk" DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1" DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28" DefaultMinSDK = "28"
DefaultTargetSDK = "35" DefaultTargetSDK = "35"
DefaultIconPath = "internal/assets/keepassgo-icon.png" DefaultIconPath = "assets/keepassgo-icon.png"
) )
type Config struct { type Config struct {
@@ -26,7 +25,6 @@ type Config struct {
AppID string AppID string
APKOut string APKOut string
Version string Version string
Ldflags string
MinSDK string MinSDK string
TargetSDK string TargetSDK string
IconPath string IconPath string
@@ -40,7 +38,6 @@ func DefaultConfig() Config {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath, IconPath: DefaultIconPath,
@@ -52,13 +49,12 @@ func (c Config) GogioArgs() []string {
"-target", "android", "-target", "android",
"-buildmode", "exe", "-buildmode", "exe",
"-appid", c.AppID, "-appid", c.AppID,
"-ldflags", c.Ldflags,
"-o", c.APKOut, "-o", c.APKOut,
"-version", c.Version, "-version", c.Version,
"-minsdk", c.MinSDK, "-minsdk", c.MinSDK,
"-targetsdk", c.TargetSDK, "-targetsdk", c.TargetSDK,
"-icon", c.IconPath, "-icon", c.IconPath,
"./cmd/keepassgo", ".",
} }
} }
+1 -4
View File
@@ -15,13 +15,12 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
"-target", "android", "-target", "android",
"-buildmode", "exe", "-buildmode", "exe",
"-appid", DefaultAppID, "-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut, "-o", DefaultAPKOut,
"-version", DefaultVersion, "-version", DefaultVersion,
"-minsdk", DefaultMinSDK, "-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK, "-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath, "-icon", DefaultIconPath,
"./cmd/keepassgo", ".",
} }
if got := cfg.GogioArgs(); !slices.Equal(got, want) { if got := cfg.GogioArgs(); !slices.Equal(got, want) {
@@ -53,7 +52,6 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"), IconPath: filepath.Join(root, "icon.png"),
@@ -79,7 +77,6 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
} }
@@ -5,7 +5,7 @@ import (
systemclipboard "github.com/atotto/clipboard" systemclipboard "github.com/atotto/clipboard"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
var ErrUnsupportedTarget = errors.New("unsupported clipboard target") var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
@@ -4,7 +4,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/vault"
) )
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) { func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
}, },
}, },
} }
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target target Target
want string want string
}{ }{
{name: "username", target: TargetUsername, want: "dannyocean"}, {name: "username", target: TargetUsername, want: "joejulian"},
{name: "password", target: TargetPassword, want: "token-1"}, {name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"}, {name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter var writer memoryWriter
service := Service{Writer: &writer} service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil { if err := service.Copy(model, "git-server", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err) t.Fatalf("Copy() error = %v", err)
} }
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
} }
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}}, Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
} }
err = service.Copy(model, "vault-console", Target("unsupported")) err = service.Copy(model, "git-server", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) { if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
} }
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}} service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "vault-console", Password: "token-1"}, {ID: "git-server", Password: "token-1"},
}, },
} }
err := service.Copy(model, "vault-console", TargetPassword) err := service.Copy(model, "git-server", TargetPassword)
if !errors.Is(err, ErrWriteFailed) { if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err) t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
} }
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"google.golang.org/grpc"
)
type bridgeConfig struct {
grpcAddr string
}
func main() {
cfg := bridgeConfig{
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
}
if len(os.Args) > 1 {
switch strings.TrimSpace(os.Args[1]) {
case "install-native-host":
if err := runInstallNativeHost(os.Args[2:]); err != nil {
fail(err)
}
return
case "status":
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
fail(err)
}
return
}
}
if err := runNativeMessage(cfg); err != nil {
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
os.Exit(1)
}
}
func runInstallNativeHost(args []string) error {
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
outputPath := fs.String("output", "", "native host manifest output path")
if err := fs.Parse(args); err != nil {
return err
}
path := strings.TrimSpace(*binaryPath)
if path == "" {
resolved, err := defaultBinaryPath()
if err != nil {
return err
}
path = resolved
}
resolvedExtensionID := strings.TrimSpace(*extensionID)
if resolvedExtensionID == "" {
keyValue := strings.TrimSpace(*extensionKey)
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
if err != nil {
return err
}
keyValue = string(data)
}
if keyValue != "" {
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
if err != nil {
return err
}
resolvedExtensionID = derivedID
}
}
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, installed)
return nil
}
func runStatus(cfg bridgeConfig, args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil {
return err
}
req := browserbridge.Request{
Action: "status",
BearerToken: strings.TrimSpace(*token),
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func runNativeMessage(cfg bridgeConfig) error {
req, err := browserbridge.ReadRequest(os.Stdin)
if err != nil {
return err
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
defer func() { _ = conn.Close() }()
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
}
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
}
func defaultBinaryPath() (string, error) {
return browserbridge.ResolveBridgeBinaryPath("")
}
func resolveGlobalGRPCAddr(args []string) string {
addr := grpcaddr.Default(runtime.GOOS)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--grpc-addr="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
}
}
return addr
}
func stripGlobalGRPCAddrFlags(args []string) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
i++
continue
case strings.HasPrefix(arg, "--grpc-addr="):
continue
default:
out = append(out, args[i])
}
}
return out
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
-7
View File
@@ -1,7 +0,0 @@
package main
import "git.julianfamily.org/keepassgo/internal/appui"
func main() {
appui.Main()
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation - list navigation
- search focus - search focus
- new-entry focus transitions - new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go). - Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go).
## Current screen-reader boundary ## Current screen-reader boundary
-125
View File
@@ -1,125 +0,0 @@
# Browser Extension
KeePassGO browser integration uses:
- the existing local gRPC API in KeePassGO
- API tokens for authorization
- a tiny native messaging host for browser-to-gRPC transport adaptation
The browser extension does **not** talk to vault files directly.
## Security Model
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
- The browser extension stores the API token in browser extension storage.
- The native messaging host receives the token on each request from the extension.
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
- The native messaging host does not persist the token to disk.
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
## RPCs Used
The browser integration uses:
- `GetSessionStatus`
- `FindBrowserLogins`
- `GetBrowserCredential`
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
## Default Listener
On desktop KeePassGO listens on a Unix socket by default:
- primary location: under the user runtime directory
- fallback: `/run/user/<uid>` if present
- final fallback: a private directory under the system temp directory
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
```bash
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
```
## Native Host
Build the bridge:
```bash
go build ./cmd/keepassgo-browser-bridge
```
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
Install a Firefox native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
```
Install a Chromium native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
```
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
For a fixed Chromium ID:
1. Keep a stable Chromium extension signing key outside the repo.
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
## Extension Setup
Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page.
3. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome:
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
3. Configure the API token in the extension settings page.
## Current Browser Flow
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
- The toolbar badge shows when KeePassGO found matches for the current page.
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
For extension-side regression checks, run:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
For a reproducible real-browser Chromium validation harness, run:
```bash
make browser-extension-validate
```
That target:
- validates the Firefox flow by default with a temporary addon install
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
- builds the native messaging bridge
- starts a stub KeePassGO gRPC server and a local login page
- drives the browser through inline match discovery, approval visibility, and fill completion
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
## Required Token Scope
At minimum, the browser token should have policy rules allowing:
- `list_entries` for the groups you want the browser to search
- `copy_username` for entries the browser may fill
- `copy_password` for entries the browser may fill
- `copy_url` for entries the browser may confirm against page URL
-148
View File
@@ -1,148 +0,0 @@
# Local-First Remote Sync Plan
## Goal
Redesign remote-backed vault handling so every platform uses the same local-first model:
- every vault is a local KDBX file first
- remote sync is an optional binding on top of that local file
- shared remote configuration lives in the vault
- user-specific remote credentials live in the vault
- app-local state stores only non-secret binding metadata
Android adds only one platform-specific capability on top of that model:
- share/import the initial local KDBX file between devices
## Product Rules
1. A remote-backed vault must always have a local cache KDBX file.
2. Opening a remote-backed vault should open the local KDBX first.
3. Shared remote configuration must be stored in the vault, not only in app state.
4. Remote credentials must not be stored in plaintext app-local state.
5. Remote credentials should be stored in the vault and resolved by a stable reference.
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
7. Sync must support both manual and automatic modes.
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
## Target Data Model
### In-Vault Shared Remote Profile
Store a reusable remote profile in the vault with fields such as:
- profile ID
- profile name
- backend type, initially WebDAV
- base URL
- remote object path
- optional notes or labels
- default sync policy, if shared defaults are desirable
### In-Vault User Credential Binding
Store user-specific credentials in the vault as normal vault data, referenced by:
- remote profile ID
- credential entry UUID, or another stable internal reference
- optional username field override if needed
The credential entry should contain the actual username/password or token.
### Local App State
Persist only non-secret binding state such as:
- local vault path
- selected remote profile ID
- selected credential entry reference
- sync policy override
- last sync metadata
- conflict or recovery markers
## Core Flows
### Create Or Configure Remote Sync
1. Open or create a local vault.
2. Create or edit a shared remote profile in that vault.
3. Create or select a credential entry in that vault.
4. Bind the local vault to the selected remote profile and credential reference.
5. Choose manual or automatic sync behavior.
### Reopen Existing Remote-Backed Vault
1. Open the local vault file from app state.
2. Resolve the selected remote profile from vault contents.
3. Resolve the credential entry from vault contents.
4. Offer or perform sync based on the binding policy.
### Bootstrap A New Android Device
1. Share the local KDBX file through Android Sharesheet.
2. Import and open that KDBX locally on the new device.
3. Select a remote profile stored in the vault.
4. Select or create that users credential entry in the vault.
5. Bind the local vault as the cache for the remote-backed setup.
## Migration Requirements
1. Migrate existing remote connections that save credentials in app state.
2. On first open after upgrade, move any recoverable remote credentials into the vault.
3. Replace saved plaintext credential state with a vault credential reference.
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
5. Remove legacy local plaintext credential persistence after migration is complete.
## Implementation Phases
### Phase 1: Domain Model
- define remote profile structures independent of Gio UI
- define credential reference structures independent of Gio UI
- define sync binding state independent of Gio UI
- add behavior tests for local-first remote-backed vaults
### Phase 2: Vault Storage
- persist remote profiles in the vault
- persist credential references in the vault
- resolve credentials from normal vault entries
- add behavior tests for read/write and lookup semantics
### Phase 3: State And Open Flow
- shrink app state to non-secret metadata only
- update open flows to always prefer the local cache vault
- update reopen behavior on all platforms to use the same model
- add migration coverage for old remote state
### Phase 4: Sync Binding
- bind a local vault to a selected remote profile
- support manual sync
- support automatic sync on open/save
- define conflict and remote-failure handling for the local cache model
### Phase 5: Android Bootstrap
- add Android Sharesheet export of the current local KDBX
- add Android import flow for a shared KDBX
- keep the remote pivot flow consistent with desktop after local open
## Open Questions
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
3. Should automatic sync run only on open/save initially, or also on app resume?
4. How should multiple remote profiles per vault be presented in the UI?
5. What should happen when the credential entry reference no longer resolves?
## Recommended First Slice
Implement the shared domain model and tests first:
- model a local vault plus optional remote binding
- define in-vault remote profile and credential reference semantics
- add tests proving app state no longer needs plaintext remote credentials
That slice standardizes the architecture before any Android-specific sharing work begins.
+3 -4
View File
@@ -2,9 +2,10 @@ module git.julianfamily.org/keepassgo
go 1.26 go 1.26
replace gioui.org/cmd => ./third_party/gioui-cmd
require ( require (
gioui.org v0.8.0 gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -17,7 +18,6 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,7 +74,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -193,7 +192,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
+2 -10
View File
@@ -39,15 +39,9 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -230,8 +224,6 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -677,8 +669,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-123
View File
@@ -1,123 +0,0 @@
package api
import (
"context"
"errors"
"net"
"os"
"testing"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"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,
apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationManageVault,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root"},
},
},
),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
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() }()
network, endpoint, err := grpcaddr.Parse(host.Address())
if err != nil {
t.Fatalf("Parse(host.Address()) error = %v", err)
}
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial(network, endpoint)
}),
)
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")
}
}
func TestStartHostServesOverUnixSocket(t *testing.T) {
t.Parallel()
socketDir := t.TempDir()
socketPath := socketDir + "/keepassgo.sock"
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
if got := host.Address(); got != "unix://"+socketPath {
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
}
if _, err := os.Stat(socketPath); err != nil {
t.Fatalf("Stat(socketPath) error = %v", err)
}
if err := host.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
}
}
-141
View File
@@ -1,141 +0,0 @@
package appstate
import (
"fmt"
"strings"
"git.julianfamily.org/keepassgo/internal/vault"
)
type SyncMode string
const (
SyncModeManual SyncMode = "manual"
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
)
type RemoteBinding struct {
LocalVaultPath string `json:"localVaultPath"`
RemoteProfileID string `json:"remoteProfileId"`
CredentialEntryID string `json:"credentialEntryId"`
SyncMode SyncMode `json:"syncMode,omitempty"`
}
type ResolvedRemoteBinding struct {
Profile vault.RemoteProfile
Credentials vault.Entry
}
type RemoteBindingInput struct {
LocalVaultPath string
RemoteProfileID string
RemoteProfileName string
BaseURL string
RemotePath string
CredentialEntryID string
CredentialTitle string
Username string
Password string
CredentialPath []string
SyncMode SyncMode
}
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
if err != nil {
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
}
credentials, err := model.EntryByID(b.CredentialEntryID)
if err != nil {
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
}
return ResolvedRemoteBinding{
Profile: profile,
Credentials: credentials,
}, nil
}
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
if model == nil {
return RemoteBinding{}, fmt.Errorf("model is required")
}
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
input.BaseURL = strings.TrimSpace(input.BaseURL)
input.RemotePath = strings.TrimSpace(input.RemotePath)
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
input.Username = strings.TrimSpace(input.Username)
switch {
case input.LocalVaultPath == "":
return RemoteBinding{}, fmt.Errorf("local vault path is required")
case input.RemoteProfileID == "":
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
case input.BaseURL == "":
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
case input.RemotePath == "":
return RemoteBinding{}, fmt.Errorf("remote path is required")
case input.CredentialEntryID == "":
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
case input.Password == "":
return RemoteBinding{}, fmt.Errorf("credential password is required")
}
if input.RemoteProfileName == "" {
input.RemoteProfileName = input.RemoteProfileID
}
if input.CredentialTitle == "" {
input.CredentialTitle = "Remote Sign-In"
}
model.UpsertRemoteProfile(vault.RemoteProfile{
ID: input.RemoteProfileID,
Name: input.RemoteProfileName,
Backend: vault.RemoteBackendWebDAV,
BaseURL: input.BaseURL,
Path: input.RemotePath,
})
model.UpsertEntry(vault.Entry{
ID: input.CredentialEntryID,
Title: input.CredentialTitle,
Username: input.Username,
Password: input.Password,
URL: input.BaseURL,
Path: append([]string(nil), input.CredentialPath...),
})
return RemoteBinding{
LocalVaultPath: input.LocalVaultPath,
RemoteProfileID: input.RemoteProfileID,
CredentialEntryID: input.CredentialEntryID,
SyncMode: normalizeSyncMode(input.SyncMode),
}, nil
}
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
if model == nil {
return fmt.Errorf("model is required")
}
if strings.TrimSpace(binding.RemoteProfileID) == "" {
return fmt.Errorf("remote profile id is required")
}
if strings.TrimSpace(binding.CredentialEntryID) == "" {
return fmt.Errorf("credential entry id is required")
}
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
model.RemoveEntryByID(binding.CredentialEntryID)
return nil
}
func normalizeSyncMode(mode SyncMode) SyncMode {
switch mode {
case SyncModeAutomaticOnOpenSave:
return SyncModeAutomaticOnOpenSave
default:
return SyncModeManual
}
}
-250
View File
@@ -1,250 +0,0 @@
package appstate
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "linuscaldwell-webdav",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
Path: []string{"Crew", "Internet"},
},
},
RemoteProfiles: []vault.RemoteProfile{
{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
},
},
}
binding := RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "linuscaldwell-webdav",
SyncMode: SyncModeAutomaticOnOpenSave,
}
resolved, err := binding.Resolve(model)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
}
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
}
if got := resolved.Credentials.Username; got != "linuscaldwell" {
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
}
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
}
}
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
},
}
_, err := (RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "missing-creds",
}).Resolve(model)
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
}
model.RemoteProfiles = []vault.RemoteProfile{{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
}}
_, err = (RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "missing-creds",
}).Resolve(model)
if !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
}
}
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
t.Parallel()
content, err := json.Marshal(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
}
text := string(content)
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
if strings.Contains(text, disallowed) {
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
}
}
}
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
t.Parallel()
var model vault.Model
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
RemoteProfileName: "Bellagio Vault",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
CredentialTitle: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
CredentialPath: []string{"Crew", "Internet"},
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
}
if len(model.RemoteProfiles) != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
}
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
}
credentials, err := model.EntryByID("remote-creds-1")
if err != nil {
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
}
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
}
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
}
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
}
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
t.Fatalf("binding = %#v, want only vault references", binding)
}
}
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
input RemoteBindingInput
}{
{
name: "missing_local_vault_path",
input: RemoteBindingInput{
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
},
},
{
name: "missing_remote_base_url",
input: RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
},
},
{
name: "missing_credential_entry_id",
input: RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
Password: "bellagio-pass-1",
},
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var model vault.Model
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
}
})
}
}
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
}},
}
err := RemoveRemoteBinding(&model, RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if got := len(model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
-107
View File
@@ -1,107 +0,0 @@
package api
import (
"strings"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
type AuditQuickFilter struct {
Label string
Query string
}
func Operations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationListTemplates,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationMutateTemplate,
apitokens.OperationGeneratePassword,
apitokens.OperationManageVault,
}
}
func AuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func AuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func CompactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func AuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
AuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
AuditOperationLabel(event.Operation),
FormatResourcePath(event.Resource.Path),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
package layout
type Mode string
const (
ModeLocked Mode = "locked"
ModeStatic Mode = "static"
ModeEmpty Mode = "empty"
ModeEditor Mode = "editor"
ModeView Mode = "view"
)
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
switch {
case isLocked:
return ModeLocked
case hasStaticPanel:
return ModeStatic
case !hasSelectedEntry && !editing:
return ModeEmpty
case editing:
return ModeEditor
default:
return ModeView
}
}
-17
View File
@@ -1,17 +0,0 @@
package detail
type EmptyState struct {
Title string
Body string
}
type VaultSummary struct {
Title string
Detail string
Context string
}
type AttachmentItem struct {
Name string
Size int
}
-64
View File
@@ -1,64 +0,0 @@
package editor
import "strings"
type Field string
const (
FieldID Field = "id"
FieldTitle Field = "title"
FieldUsername Field = "username"
FieldPassword Field = "password"
FieldURL Field = "url"
FieldPath Field = "path"
FieldTags Field = "tags"
FieldPasswordProfile Field = "password-profile"
FieldNotes Field = "notes"
FieldFields Field = "fields"
FieldHistoryIndex Field = "history-index"
)
func Label(field Field) string {
switch field {
case FieldID:
return "ID"
case FieldTitle:
return "Title"
case FieldUsername:
return "Username"
case FieldPassword:
return "Password"
case FieldURL:
return "URL"
case FieldPath:
return "Path"
case FieldTags:
return "Tags"
case FieldPasswordProfile:
return "Password Profile"
case FieldNotes:
return "Notes"
case FieldFields:
return "Custom Fields"
case FieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
func FocusOrder() []Field {
return []Field{
FieldID,
FieldTitle,
FieldUsername,
FieldPassword,
FieldURL,
FieldPath,
FieldTags,
FieldPasswordProfile,
FieldNotes,
FieldFields,
FieldHistoryIndex,
}
}
File diff suppressed because it is too large Load Diff
-335
View File
@@ -1,335 +0,0 @@
package appui
import (
"fmt"
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Flexed(1, u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
rowDims := cluster.layout(gtx, u)
if u.usesCompactViewport() {
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
}
if u.usesCompactViewport() {
cluster.prepareCompactMenus(gtx, u)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
}
return rowDims
}
type headerActionCluster struct {
Metrics headerlayout.ActionMetrics
SyncMenu layout.Widget
MainMenu layout.Widget
}
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
cluster := headerActionCluster{
SyncMenu: u.syncMenuWidget(),
MainMenu: u.mainMenuWidget(),
}
spacing := gtx.Dp(unit.Dp(8))
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
if !u.usesCompactViewport() {
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
}
return cluster
}
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
rowOps := op.Record(gtx.Ops)
c.Metrics.RowDims = c.layoutRow(gtx, u)
rowCall := rowOps.Stop()
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
rowCall.Add(gtx.Ops)
return c.Metrics.RowDims
})
}
func (c headerActionCluster) activeMenu() layout.Widget {
switch {
case c.ShowSyncMenu():
return c.SyncMenu
case c.ShowMainMenu():
return c.MainMenu
default:
return nil
}
}
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
return c.Metrics.SyncDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
c.Metrics.LockDims = btn.Layout(gtx)
return c.Metrics.LockDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
return c.Metrics.MainDims
}),
)
}
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
compactSurface := headerlayout.DropdownSurface{
ContainerWidth: gtx.Constraints.Max.X,
LeftInset: u.frameInsetPx,
TopInset: u.frameInsetPx,
}
if c.ShowSyncMenu() {
u.phoneSyncMenuVisible = true
u.maybeLogHeaderMenuToggle("sync-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
u.phoneSyncMenuOrigin = placement.Origin
u.phoneSyncMenuSize = placement.Size
u.phoneSyncMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
}
if c.ShowMainMenu() {
u.phoneMainMenuVisible = true
u.maybeLogHeaderMenuToggle("main-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
u.phoneMainMenuOrigin = placement.Origin
u.phoneMainMenuSize = placement.Size
u.phoneMainMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
}
}
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
menu := c.activeMenu()
if menu == nil {
return layout.Dimensions{}
}
fullWidthGTX := gtx
fullWidthGTX.Constraints.Min = image.Point{}
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
dims := layout.E.Layout(fullWidthGTX, menu)
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
}
type headerButtonBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
return headerButtonBounds{
SyncPrimary: bounds.SyncPrimary.Add(origin),
SyncToggle: bounds.SyncToggle.Add(origin),
Lock: bounds.Lock.Add(origin),
MainMenu: bounds.MainMenu.Add(origin),
}
}
func (b headerButtonBounds) logLine(mode string) string {
return fmt.Sprintf(
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
mode,
formatHeaderRect(b.SyncPrimary),
formatHeaderRect(b.SyncToggle),
formatHeaderRect(b.Lock),
formatHeaderRect(b.MainMenu),
)
}
func formatHeaderRect(rect image.Rectangle) string {
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
if u.syncMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
})
}
if u.mainMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
})
}
return layout.Dimensions{}
}
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
dims := cluster.layoutMenuRow(gtx)
if dims.Size.Y == 0 {
return dims
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
)
}
func (u *ui) syncMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
}
func (u *ui) mainMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
}
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
func (u *ui) headerMenusUseOverlayModel() bool { return true }
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.usesCompactViewport() {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
func (u *ui) mainMenuWidget() layout.Widget {
if !u.mainMenuOpen {
return nil
}
return u.mainMenu
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
},
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
},
}
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
}
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
return headerlayout.MenuActionWidth(gtx, rows)
}
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, width, child)
}
-131
View File
@@ -1,131 +0,0 @@
package layout
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
func AnchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
x := rowOriginX + triggerRightX - menuWidth
if x < 0 {
return 0
}
if x+menuWidth > containerWidth {
return max(0, containerWidth-menuWidth)
}
return x
}
type DropdownAnchor struct {
TriggerRightX int
TriggerBottomY int
}
func (a DropdownAnchor) Point() image.Point {
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
}
type DropdownSurface struct {
ContainerWidth int
LeftInset int
TopInset int
}
type DropdownPlacement struct {
Anchor DropdownAnchor
Origin image.Point
Size image.Point
}
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
return menuGTX
}
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
y := s.TopInset + anchor.TriggerBottomY
return image.Pt(x, y)
}
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
menuGTX := s.MenuConstraints(gtx)
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
menuCall := menuOps.Stop()
menuOrigin := s.Origin(anchor, menuDims.Size.X)
return DropdownPlacement{
Anchor: anchor,
Origin: menuOrigin,
Size: menuDims.Size,
}, menuCall
}
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
placement, menuCall := s.Place(gtx, anchor, menu)
stack := op.Offset(placement.Origin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
type ActionMetrics struct {
RowOriginX int
Spacing int
SyncInnerSpacing int
RowDims layout.Dimensions
SyncDims layout.Dimensions
SyncPrimaryDims layout.Dimensions
SyncToggleDims layout.Dimensions
LockDims layout.Dimensions
MainDims layout.Dimensions
}
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
return DropdownAnchor{
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
TriggerBottomY: m.RowDims.Size.Y,
}
}
func (m ActionMetrics) MainAnchor() DropdownAnchor {
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
return DropdownAnchor{
TriggerRightX: m.RowOriginX + triggerRightX,
TriggerBottomY: m.RowDims.Size.Y,
}
}
type ActionBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func (m ActionMetrics) Bounds() ActionBounds {
top := 0
syncLeft := m.RowOriginX
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
mainLeft := lock.Max.X + m.Spacing
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
return ActionBounds{
SyncPrimary: syncPrimary,
SyncToggle: syncToggle,
Lock: lock,
MainMenu: mainMenu,
}
}
-59
View File
@@ -1,59 +0,0 @@
package layout
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
macro := op.Record(gtx.Ops)
contentDims := w(measureGTX)
_ = macro.Stop()
if logger != nil {
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
}
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
maxWidth := gtx.Constraints.Max.X
if maxWidth > 0 && width > maxWidth {
width = maxWidth
}
if width > 0 {
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
}
dims := card(gtx, w)
if logger != nil {
logger("intrinsic-card", gtx.Constraints, dims)
}
return dims
}
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0
for _, row := range rows {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
macro := op.Record(gtx.Ops)
dims := row(measureGTX)
_ = macro.Stop()
if dims.Size.X > width {
width = dims.Size.X
}
}
return width
}
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
if width <= 0 {
return child(gtx)
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.E.Layout(gtx, child)
}
-54
View File
@@ -1,54 +0,0 @@
package header
import (
"image/color"
"image"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
if logger != nil {
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
}
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
for i, row := range rows {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
current := row
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
}))
}
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if logger != nil {
logger("rows", gtx.Constraints, dims)
}
return dims
}, card, logger)
if logger != nil {
logger("card", gtx.Constraints, dims)
}
return dims
}
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
btn := material.IconButton(theme, click, icon, "Menu")
if open {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
-362
View File
@@ -1,362 +0,0 @@
package appui
import (
"image"
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/internal/appstate"
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
spacing := unit.Dp(4)
if u.usesCompactViewport() {
spacing = unit.Dp(3)
}
var primaryDims layout.Dimensions
var toggleDims layout.Dimensions
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
return primaryDims
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
toggleDims = u.syncMenuToggle(gtx)
return toggleDims
}),
)
return groupDims, primaryDims, toggleDims
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
if u.syncMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.usesCompactViewport() {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenuWidget() layout.Widget {
if !u.syncMenuOpen {
return nil
}
return u.syncMenu
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
model := u.buildSyncMenuModel()
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
actionRows := u.syncMenuActionRows(model)
actionWidth := menuActionWidth(gtx, actionRows)
menu := func(gtx layout.Context) layout.Dimensions {
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
})
}
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
if reserveWidth <= 0 {
return menu(gtx)
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(menu),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
}),
)
}
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
spacing := gtx.Dp(unit.Dp(8))
if u.usesCompactViewport() {
spacing = gtx.Dp(unit.Dp(8))
}
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
lockOps := op.Record(gtx.Ops)
lockDims := func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}(measureGTX)
_ = lockOps.Stop()
menuOps := op.Record(gtx.Ops)
menuDims := u.mainMenuButtonGroup(measureGTX)
_ = menuOps.Stop()
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
}
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
},
}
if model.ShowShare {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
})
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
})
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
})
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
})
}
return rows
}
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
rows := u.syncMenuPrimaryRows(model, actionWidth)
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
if model.ShowSaveCurrentBinding {
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
}
return rows
}
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
rows := []layout.FlexChild{}
if model.ShowShare {
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}))
}
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
}
return rows
}
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, click, label)
})
})
}
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
return nil
}
rows := []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
}
if !model.ShowSelectors {
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
} else {
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
return rows
}
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
summary := model.SavedBindingSummary
if !summary.OK {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
return []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
}),
}
}
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteProfileID == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
label := entry.Title
if entry.Username != "" {
label += " · " + entry.Username
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
return rows
}
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
model := syncmodel.MenuModel{
HasOpenVault: u.hasOpenVault(),
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
RemotePath: strings.TrimSpace(u.remotePath.Text()),
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
RemotePassword: u.remotePassword.Text(),
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
return model
}
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return syncmodel.MenuBindingSummary{}
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return syncmodel.MenuBindingSummary{}
}
credentialLabel := entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel := "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return syncmodel.MenuBindingSummary{
ProfileLabel: profile.Name,
CredentialLabel: credentialLabel,
SyncLabel: syncLabel,
OK: true,
}
}
-9
View File
@@ -1,9 +0,0 @@
package lifecycle
type OpenIntent string
const (
OpenIntentNone OpenIntent = ""
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
)
-778
View File
@@ -1,778 +0,0 @@
package appui
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/appstate"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}); err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
return err
}
u.vaultPath.SetText(u.saveAsTargetPath())
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) openVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.OpenVault(path, key); err != nil {
return err
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}
func (u *ui) startOpenVaultAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open vault", u.openVaultAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open vault", err)
u.requestMasterPassFocus = true
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
u.requestMasterPassFocus = true
return
}
u.lastLifecycleAction = "open vault"
u.runBackgroundAction("open vault", func() (func() error, error) {
prepared, err := session.PrepareLocalOpen(path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
})
}
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) lifecycleRemoteSyncActionLabel() string {
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Open Vault And Set Up Remote Sync"
}
if hasBoundRecentRemote(u.recentRemotes, path) {
return "Open Vault And Open Remote Sync Settings"
}
return "Open Vault And Set Up Remote Sync"
}
func (u *ui) beginLifecycleRemoteSyncOpen() {
path := strings.TrimSpace(u.vaultPath.Text())
switch {
case path == "":
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
case hasBoundRecentRemote(u.recentRemotes, path):
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
default:
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
}
u.startOpenVaultAction()
}
func (u *ui) applyPendingLifecycleOpenIntent() {
intent := u.pendingLifecycleOpenIntent
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
switch intent {
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
u.openRemoteSyncSetupDialog()
}
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsAction() error {
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.noteRecentVault(path)
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
return err
} else if ok {
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
return err
}
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
strings.TrimSpace(u.remotePath.Text()),
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startOpenRemoteAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open remote vault", u.openRemoteAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
u.requestMasterPassFocus = true
return
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
remotePath := strings.TrimSpace(u.remotePath.Text())
u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) {
binding, bindingOK := u.selectedVaultRemoteBinding()
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
if err != nil {
return nil, err
}
resolved, err := binding.Resolve(preparedLocal.Model)
if err != nil {
return nil, err
}
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}, resolved.Profile.Path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(preparedLocal)
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
manager.ApplyPreparedRemoteOpen(preparedRemote)
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
}
if u.hasOpenVault() {
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
return nil, err
} else if ok {
client = webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
remotePath = resolved.Profile.Path
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
}
}
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedRemoteOpen(prepared)
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.requestMasterPassFocus = true
u.adoptStateCurrentPath()
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startUnlockAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("unlock vault", u.unlockAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
u.requestMasterPassFocus = true
return
}
encoded := append([]byte(nil), manager.EncodedBytes()...)
u.runBackgroundAction("unlock vault", func() (func() error, error) {
prepared, err := session.PrepareUnlock(encoded, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) loadSecuritySettingsFromSession() {
settings, err := u.state.SecuritySettings()
if err != nil {
return
}
u.securityCipher.SetText(settings.Cipher)
u.securityKDF.SetText(settings.KDF)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.syncMenuOpen = false
u.filter()
return nil
}
func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeAdvanced
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
}
func (u *ui) openRemoteSyncSetupDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
u.syncSourceMode = syncSourceRemote
u.syncDirection = syncDirectionPush
u.syncSetupAutomatic.Value = true
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
u.syncSetupAutomatic.Value = false
}
}
func (u *ui) clearSyncLocalImport() {
u.syncLocalImportName = ""
u.syncLocalImportContent = nil
}
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
name := strings.TrimSpace(u.syncLocalImportName)
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
return "", nil, false
}
return name, append([]byte(nil), u.syncLocalImportContent...), true
}
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode {
case syncSourceRemote:
return syncSourceRemote
default:
return syncSourceLocal
}
}
func sanitizeSyncDirection(direction syncDirection) syncDirection {
switch direction {
case syncDirectionPush:
return syncDirectionPush
default:
return syncDirectionPull
}
}
func (u *ui) advancedSyncAction() error {
switch u.syncDirection {
case syncDirectionPush:
return u.advancedSyncToAction()
default:
return u.advancedSyncFromAction()
}
}
func (u *ui) advancedSyncFromAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
client := webdav.Client{
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
return err
}
default:
if name, content, ok := u.selectedSyncLocalImport(); ok {
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
break
}
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeFromLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) startChooseSyncLocalSourceAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose sync path", func() error {
u.clearSyncLocalImport()
return u.chooseExistingFileAction(&u.syncLocalPath)
})
return
}
u.runBackgroundAction("choose sync file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
label := "Selected Android vault"
return func() error {
u.syncLocalImportName = label
u.syncLocalImportContent = append([]byte(nil), content...)
u.syncLocalPath.SetText(label)
return nil
}, nil
})
}
func pickedDocumentName(file io.ReadCloser, fallback string) string {
if named, ok := file.(interface{ Name() string }); ok {
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
return base
}
}
fallback = filepath.Base(strings.TrimSpace(fallback))
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
return "selected-vault.kdbx"
}
return fallback
}
func (u *ui) startChooseVaultPathAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
return
}
u.runBackgroundAction("choose vault file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
name := pickedDocumentName(file, "selected-vault.kdbx")
return func() error {
return u.importSharedVaultBytesAction(name, content)
}, nil
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
client := webdav.Client{
BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
return err
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
return err
}
u.showStatusMessage("Remote sync is set up for this vault.")
}
default:
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeToLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
baseURL = strings.TrimSpace(baseURL)
remotePath = strings.TrimSpace(remotePath)
if baseURL == "" || remotePath == "" {
return fmt.Errorf("remote setup requires base URL and path")
}
input := appstate.RemoteBindingInput{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
CredentialTitle: "WebDAV Sign-In" + func() string {
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
return " · " + user
}
return ""
}(),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.syncSetupMode(),
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
u.remoteBaseURL.SetText(baseURL)
u.remotePath.SetText(remotePath)
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
u.remotePassword.SetText(u.syncRemotePassword.Text())
u.noteRecentRemote(baseURL, remotePath)
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
return err
}
u.lifecycleMode = "local"
u.vaultPath.SetText(target)
u.noteRecentVault(target)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
u.filter()
return nil
}
func (u *ui) currentShareableVaultPath() string {
return strings.TrimSpace(u.vaultPath.Text())
}
func (u *ui) shareCurrentVaultAction() error {
if u.vaultSharer == nil {
return fmt.Errorf("vault sharing is not available on this platform")
}
path := u.currentShareableVaultPath()
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.Save(); err != nil {
return err
}
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
}
-12
View File
@@ -1,12 +0,0 @@
package layout
type TopSection string
const (
TopSearch TopSection = "search"
TopNavigation TopSection = "navigation"
TopPath TopSection = "path"
TopGroup TopSection = "group"
TopGroupTools TopSection = "group_tools"
TopPrimary TopSection = "primary"
)
-8
View File
@@ -1,8 +0,0 @@
package list
type EntriesSectionState struct {
Path []string
SearchQuery string
SelectedEntryID string
Editing bool
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
//go:build android
package platform
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <android/log.h>
#include <stdlib.h>
*/
import "C"
import "unsafe"
func LogInfo(tag, msg string) {
ctag := C.CString(tag)
defer C.free(unsafe.Pointer(ctag))
cmsg := C.CString(msg)
defer C.free(unsafe.Pointer(cmsg))
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
}
@@ -1,184 +0,0 @@
//go:build android
package platform
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <jni.h>
#include <stdlib.h>
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
return (*env)->GetObjectClass(env, obj);
}
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetMethodID(env, clazz, name, sig);
}
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetStaticMethodID(env, clazz, name, sig);
}
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallObjectMethodA(env, obj, method, args);
}
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
}
static jvalue jni_ValueObject(jobject obj) {
jvalue value;
value.l = obj;
return value;
}
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
return (*env)->ExceptionOccurred(env);
}
static void jni_ExceptionClear(JNIEnv *env) {
(*env)->ExceptionClear(env);
}
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
return (*env)->NewString(env, unicodeChars, len);
}
static int jni_IsNull(jobject obj) {
return obj == NULL;
}
*/
import "C"
import (
"fmt"
"strings"
"unicode/utf16"
"unsafe"
"gioui.org/app"
_ "unsafe"
)
type androidVaultSharer struct{}
//go:linkname gioJavaVM gioui.org/app.javaVM
func gioJavaVM() *C.JavaVM
//go:linkname gioRunInJVM gioui.org/app.runInJVM
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
func NewVaultSharer(goos string) VaultSharer {
return androidVaultSharer{}
}
func (androidVaultSharer) ShareVault(path, title string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("vault path is required")
}
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
if C.jni_IsNull(ctx) != 0 {
return fmt.Errorf("android app context is not available")
}
var callErr error
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
if err != nil {
callErr = err
return
}
methodName := cString("shareVault")
defer C.free(unsafe.Pointer(methodName))
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
defer C.free(unsafe.Pointer(methodSig))
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
if method == nil {
callErr = androidJNIError(env, "resolve shareVault method")
if callErr == nil {
callErr = fmt.Errorf("resolve shareVault method")
}
return
}
jPath := androidJavaString(env, path)
jTitle := androidJavaString(env, title)
args := [3]C.jvalue{}
args[0] = C.jni_ValueObject(ctx)
args[1] = C.jni_ValueObject(C.jobject(jPath))
args[2] = C.jni_ValueObject(C.jobject(jTitle))
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
callErr = androidJNIError(env, "share vault")
})
return callErr
}
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
var zeroClass C.jclass
contextClass := C.jni_GetObjectClass(env, ctx)
getClassLoaderName := cString("getClassLoader")
defer C.free(unsafe.Pointer(getClassLoaderName))
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
defer C.free(unsafe.Pointer(getClassLoaderSig))
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
if getClassLoader == nil {
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve getClassLoader")
}
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
if err := androidJNIError(env, "load class loader"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(classLoader) != 0 {
return zeroClass, fmt.Errorf("android class loader is nil")
}
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
loadClassName := cString("loadClass")
defer C.free(unsafe.Pointer(loadClassName))
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
defer C.free(unsafe.Pointer(loadClassSig))
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
if loadClass == nil {
if err := androidJNIError(env, "resolve loadClass"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve loadClass")
}
jClassName := androidJavaString(env, name)
args := [1]C.jvalue{}
args[0] = C.jni_ValueObject(C.jobject(jClassName))
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(loaded) != 0 {
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
}
return C.jclass(loaded), nil
}
func androidJNIError(env *C.JNIEnv, action string) error {
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
C.jni_ExceptionClear(env)
return fmt.Errorf("%s: Java exception", action)
}
return nil
}
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
chars := utf16.Encode([]rune(s))
if len(chars) == 0 {
return C.jni_NewString(env, nil, 0)
}
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
}
func cString(value string) *C.char {
return C.CString(value)
}
@@ -1,13 +0,0 @@
//go:build !android
package platform
import "log"
func NewVaultSharer(goos string) VaultSharer {
return nil
}
func LogInfo(tag, msg string) {
log.Printf("%s: %s", tag, msg)
}
-67
View File
@@ -1,67 +0,0 @@
package platform
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
)
type VaultSharer interface {
ShareVault(path, title string) error
}
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
@@ -1,42 +0,0 @@
package platform
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := NewClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := NewClipboardWriter("linux", nil); writer != nil {
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
File diff suppressed because it is too large Load Diff
-210
View File
@@ -1,210 +0,0 @@
package appui
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"gioui.org/app"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/api"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appui/platform"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
)
func Main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180)
height := unit.Dp(760)
if strings.EqualFold(resolvedMode, "phone") {
width = unit.Dp(412)
height = unit.Dp(915)
}
go func() {
w := new(app.Window)
options := []app.Option{app.Title(productName)}
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
options = append(options, app.Size(width, height))
}
w.Option(options...)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
panic(err)
}
if !strings.EqualFold(runtime.GOOS, "android") {
os.Exit(0)
}
}()
app.Main()
}
func defaultGRPCAddr(goos string) string {
return grpcaddr.Default(goos)
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
ensureBrowserNativeHosts()
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
} else if host != nil {
ui.apiHost = host
ui.auditLog = host.Server().AuditLog()
ui.state.AuditLog = ui.auditLog
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
host.Server().SetChangeNotifier(func() {
ui.state.Dirty = true
ui.invalidate()
})
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
defer func() { _ = host.Stop() }()
}
for {
e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
ui.processBackgroundActions()
ui.layout(gtx)
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
e.Frame(gtx.Ops)
}
}
}
func ensureBrowserNativeHosts() {
if runtime.GOOS != "linux" {
return
}
appBinaryPath, err := os.Executable()
if err != nil {
return
}
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
}
}
type uiApprovalManager struct {
server *api.Server
}
func (m *uiApprovalManager) Pending() []apiapproval.Request {
if m == nil || m.server == nil {
return nil
}
return m.server.ApprovalBroker().Pending()
}
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
if m == nil || m.server == nil {
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
}
return m.server.ResolveApproval(id, outcome)
}
type uiSession struct {
model vault.Model
locked bool
}
func (s *uiSession) HasVault() bool {
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
}
func (s *uiSession) IsLocked() bool {
return s.locked
}
func (s *uiSession) IsRemote() bool {
return false
}
func (s *uiSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *uiSession) Replace(model vault.Model) {
s.model = model
}
func (s *uiSession) Lock() error {
s.locked = true
return nil
}
func (s *uiSession) Unlock(vault.MasterKey) error {
if !s.locked {
return nil
}
s.locked = false
return nil
}
func pickExistingFile() (string, error) {
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
return path, nil
}
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
return path, nil
}
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
}
func runFilePicker(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
cmd := exec.Command(name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return parsePickedFilePath(output)
}
func parsePickedFilePath(output []byte) (string, error) {
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
return line, nil
}
}
return "", fmt.Errorf("file picker did not return a path")
}
-448
View File
@@ -1,448 +0,0 @@
package appui
import (
"encoding/json"
"fmt"
"image"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
"git.julianfamily.org/keepassgo/internal/appui/platform"
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
"git.julianfamily.org/keepassgo/internal/vault"
)
const (
displayDensityDense = settingsmodel.DisplayDensityDense
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
contrastStandard = settingsmodel.ContrastStandard
contrastHigh = settingsmodel.ContrastHigh
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
)
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
Debug debugSettings `json:"debug,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
}
type debugSettings struct {
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
AutoSaveRemote bool
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
Debug debugSettings
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return settingsmodel.DefaultAccessibilityPreferences()
}
func displayDensityForDenseLayout(dense bool) string {
return settingsmodel.DisplayDensityForDenseLayout(dense)
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
return editormodel.Label(field)
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
if !u.debugLogHeaderBounds {
u.lastHeaderBoundsLog = ""
}
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
u.autoSaveRemote = false
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
u.autoSaveRemote = settings.Sync.AutoSaveRemote
u.state.AutoSaveRemote = u.autoSaveRemote
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
u.state.AutoSaveRemote = u.autoSaveRemote
}
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),
AutoSaveRemote: u.autoSaveRemote,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
if !u.debugLogHeaderBounds {
return
}
line := bounds.logLine(u.mode)
if line == u.lastHeaderBoundsLog {
return
}
platform.LogInfo("KeePassGO", line)
u.lastHeaderBoundsLog = line
}
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
}
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf(
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
menu,
placement.Anchor.TriggerRightX,
placement.Anchor.TriggerBottomY,
placement.Origin.X,
placement.Origin.Y,
placement.Size.X,
placement.Size.Y,
surface.ContainerWidth,
surface.LeftInset,
surface.TopInset,
))
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
-50
View File
@@ -1,50 +0,0 @@
package settings
type AccessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
const (
DisplayDensityDense = "dense"
DisplayDensityComfortable = "comfortable"
ContrastStandard = "standard"
ContrastHigh = "high"
KeyboardFocusStandard = "standard"
KeyboardFocusProminent = "prominent"
)
func DefaultAccessibilityPreferences() AccessibilityPreferences {
return AccessibilityPreferences{
DisplayDensity: DisplayDensityDense,
Contrast: ContrastStandard,
KeyboardFocus: KeyboardFocusStandard,
}
}
func DisplayDensityForDenseLayout(dense bool) string {
if dense {
return DisplayDensityDense
}
return DisplayDensityComfortable
}
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
normalized := DefaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case DisplayDensityDense, DisplayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case ContrastStandard, ContrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case KeyboardFocusStandard, KeyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
-119
View File
@@ -1,119 +0,0 @@
package sync
import "git.julianfamily.org/keepassgo/internal/appstate"
type SourceMode string
const (
SourceLocal SourceMode = "local"
SourceRemote SourceMode = "remote"
)
type Direction string
const (
DirectionPull Direction = "pull"
DirectionPush Direction = "push"
)
type DialogPurpose string
const (
DialogPurposeAdvanced DialogPurpose = "advanced"
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
)
type MenuModel struct {
HasOpenVault bool
HasSelectedBinding bool
ShowSelectors bool
ShowShare bool
ShowSaveCurrentBinding bool
SavedBindingSummary MenuBindingSummary
RemoteBaseURL string
RemotePath string
RemoteUsername string
RemotePassword string
SelectedVaultSyncMode appstate.SyncMode
}
type MenuBindingSummary struct {
ProfileLabel string
CredentialLabel string
SyncLabel string
OK bool
}
func (m MenuModel) SavedBindingHeading() string {
if !m.ShowSelectors {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (m MenuModel) OpenSelectedButtonLabel() string {
if !m.ShowSelectors {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
return m.HasOpenVault && !m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
func (m MenuModel) ActionLabels() []string {
labels := []string{"Open Advanced Sync"}
if m.ShowRemoteSyncSetupShortcut() {
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
}
if m.ShowDirectRemoteSyncShortcut() {
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
}
if m.ShowRemoteSyncSettingsShortcut() {
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
}
if m.ShowRemoveRemoteSyncShortcut() {
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
}
return labels
}
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
if purpose == DialogPurposeRemoteSetup {
return "Push this local vault to a WebDAV target and save that target for future sync."
}
sourceLabel := "another local vault file"
if source == SourceRemote {
sourceLabel = "another WebDAV-backed vault"
}
action := "Pull changes from"
if direction == DirectionPush {
action = "Push the current vault into"
}
return action + " " + sourceLabel + "."
}
-223
View File
@@ -1,223 +0,0 @@
package appui
import (
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(620))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.syncDialogContent)
})
}),
)
}
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
}
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote {
children := []layout.FlexChild{
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncPasswordField(gtx)
}),
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
check.Color = accentColor
return check.Layout(gtx)
}),
)
}
if len(matchingCredentials) > 0 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
for i, entry := range matchingCredentials {
i := i
entry := entry
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
}),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
}
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
}),
)
}),
)
})
}
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
field := func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.outlinedFieldState(gtx, false, field)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
-346
View File
@@ -1,346 +0,0 @@
package browserbridge
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
const (
NativeHostName = "com.keepassgo.browser"
defaultFirefoxID = "browser@keepassgo.com"
maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16
responseVersion = "1"
)
type Request struct {
Action string `json:"action"`
BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"`
Credential *Credential `json:"credential,omitempty"`
Version string `json:"version,omitempty"`
}
type Status struct {
Connected bool `json:"connected"`
Locked bool `json:"locked"`
Dirty bool `json:"dirty,omitempty"`
EntryCount uint32 `json:"entryCount,omitempty"`
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
GRPCAddress string `json:"grpcAddress,omitempty"`
}
type Match struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username,omitempty"`
URL string `json:"url,omitempty"`
Path []string `json:"path,omitempty"`
Quality string `json:"quality,omitempty"`
}
type Credential struct {
ID string `json:"id"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
URL string `json:"url,omitempty"`
}
type Connection struct {
GRPCAddress string
BearerToken string
}
type Client interface {
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
}
type Browser string
const (
BrowserFirefox Browser = "firefox"
BrowserChrome Browser = "chrome"
BrowserChromium Browser = "chromium"
)
type NativeHostManifest struct {
Name string `json:"name"`
Description string `json:"description"`
Path string `json:"path"`
Type string `json:"type"`
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
AllowedOrigins []string `json:"allowed_origins,omitempty"`
}
func DefaultFirefoxExtensionID() string {
return defaultFirefoxID
}
func ReadRequest(r io.Reader) (Request, error) {
var sizeBuf [4]byte
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
return Request{}, err
}
size := binary.LittleEndian.Uint32(sizeBuf[:])
if size == 0 || size > maxNativeMessageSize {
return Request{}, fmt.Errorf("invalid native message size %d", size)
}
body := make([]byte, size)
if _, err := io.ReadFull(r, body); err != nil {
return Request{}, err
}
var req Request
if err := json.Unmarshal(body, &req); err != nil {
return Request{}, fmt.Errorf("decode native request: %w", err)
}
return req, nil
}
func WriteResponse(w io.Writer, resp Response) error {
data, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("encode native response: %w", err)
}
if len(data) > maxNativeMessageSize {
return fmt.Errorf("native response too large: %d", len(data))
}
var sizeBuf [4]byte
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
if _, err := w.Write(sizeBuf[:]); err != nil {
return err
}
_, err = w.Write(data)
return err
}
func (r Request) Connection(grpcAddr string) (Connection, error) {
return normalizeConnection(Connection{
GRPCAddress: strings.TrimSpace(grpcAddr),
BearerToken: strings.TrimSpace(r.BearerToken),
})
}
func normalizeConnection(conn Connection) (Connection, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
}
if strings.TrimSpace(conn.BearerToken) == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
}
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
return conn, nil
}
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
conn, err := req.Connection(grpcAddr)
if err != nil {
return Response{Success: false, Error: err.Error()}
}
action := strings.TrimSpace(req.Action)
switch action {
case "status":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins":
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default:
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
}
}
func disconnectedStatus(addr string) *Status {
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
}
func availableStatus(addr string) *Status {
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
}
func inferredActionStatus(addr string, err error) *Status {
switch gstatus.Code(err) {
case gcodes.FailedPrecondition:
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
case gcodes.OK:
return availableStatus(addr)
default:
return nil
}
}
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
resp, err := client.Status(ctx)
if err != nil {
return nil, err
}
return &Status{
Connected: true,
Locked: resp.GetLocked(),
Dirty: resp.GetDirty(),
EntryCount: resp.GetEntryCount(),
PendingApprovalCount: resp.GetPendingApprovalCount(),
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
GRPCAddress: strings.TrimSpace(addr),
}, nil
}
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
out := make([]Match, 0, len(resp))
for _, match := range resp {
out = append(out, Match{
ID: match.GetId(),
Title: match.GetTitle(),
Username: match.GetUsername(),
URL: match.GetUrl(),
Path: append([]string(nil), match.GetPath()...),
Quality: match.GetQuality(),
})
}
return out, nil
}
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
id := strings.TrimSpace(entryID)
if id == "" {
return nil, fmt.Errorf("entry id is required")
}
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
return &Credential{
ID: resp.GetId(),
Username: resp.GetUsername(),
Password: resp.GetPassword(),
URL: resp.GetUrl(),
}, nil
}
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath)
if path == "" {
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
}
switch browser {
case BrowserFirefox:
id := strings.TrimSpace(extensionID)
if id == "" {
id = defaultFirefoxID
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedExtensions: []string{id},
}, nil
case BrowserChrome, BrowserChromium:
id := strings.TrimSpace(extensionID)
if id == "" {
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
}, nil
default:
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
}
}
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
normalized := strings.TrimSpace(raw)
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "\n", "")
normalized = strings.ReplaceAll(normalized, "\r", "")
normalized = strings.ReplaceAll(normalized, "\t", "")
normalized = strings.ReplaceAll(normalized, " ", "")
if normalized == "" {
return "", fmt.Errorf("chromium extension key is required")
}
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return "", fmt.Errorf("decode chromium extension key: %w", err)
}
hash := sha256.Sum256(publicKeyDER)
var builder strings.Builder
builder.Grow(chromiumIDBytes * 2)
for _, b := range hash[:chromiumIDBytes] {
builder.WriteByte('a' + ((b >> 4) & 0x0f))
builder.WriteByte('a' + (b & 0x0f))
}
return builder.String(), nil
}
func DefaultManifestPath(browser Browser) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch browser {
case BrowserFirefox:
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
case BrowserChrome:
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
case BrowserChromium:
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
default:
return "", fmt.Errorf("unsupported browser %q", browser)
}
}
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
}
-396
View File
@@ -1,396 +0,0 @@
package browserbridge
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
func TestReadRequestAndWriteResponse(t *testing.T) {
t.Parallel()
var input bytes.Buffer
body, err := json.Marshal(Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://example.invalid/login",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
t.Fatalf("binary.Write() error = %v", err)
}
if _, err := input.Write(body); err != nil {
t.Fatalf("Write() error = %v", err)
}
req, err := ReadRequest(&input)
if err != nil {
t.Fatalf("ReadRequest() error = %v", err)
}
if req.Action != "find-logins" || req.BearerToken != "secret" {
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
}
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
}
var output bytes.Buffer
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
t.Fatalf("WriteResponse() error = %v", err)
}
var size uint32
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
t.Fatalf("binary.Read() error = %v", err)
}
payload := make([]byte, size)
if _, err := output.Read(payload); err != nil {
t.Fatalf("Read() payload error = %v", err)
}
var resp Response
if err := json.Unmarshal(payload, &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if !resp.Success || resp.Version != "1" {
t.Fatalf("response = %#v, want success version 1", resp)
}
}
func TestHandleRequestFindLogins(t *testing.T) {
t.Parallel()
client := &fakeClient{
matches: []*keepassgov1.BrowserLoginMatch{
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
t.Parallel()
client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{
Locked: false,
EntryCount: 2,
PendingApprovalCount: 3,
TokenPendingApprovalCount: 1,
},
}
resp := HandleRequest(context.Background(), Request{
Action: "status",
BearerToken: "secret",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
}
if resp.Status == nil {
t.Fatal("HandleRequest(status).Status = nil, want status")
}
if got := resp.Status.PendingApprovalCount; got != 3 {
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
}
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
}
}
func TestHandleRequestGetLogin(t *testing.T) {
t.Parallel()
client := &fakeClient{
credential: &keepassgov1.GetBrowserCredentialResponse{
Id: "vault-console",
Username: "dannyocean",
Password: "token-1",
Url: "https://vault.example.invalid",
},
}
resp := HandleRequest(context.Background(), Request{
Action: "get-login",
BearerToken: "secret",
EntryID: "vault-console",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
}
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel()
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
}
if resp.Status == nil || !resp.Status.Locked {
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestRequiresBearerToken(t *testing.T) {
t.Parallel()
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
if resp.Success {
t.Fatal("HandleRequest().Success = true, want false without token")
}
}
func TestRequestConnectionDefaultsAddress(t *testing.T) {
t.Parallel()
req := Request{Action: "status", BearerToken: "secret"}
conn, err := req.Connection("")
if err != nil {
t.Fatalf("Connection(\"\") error = %v", err)
}
if conn.GRPCAddress == "" {
t.Fatal("Connection().GRPCAddress = empty, want default address")
}
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
}
}
func TestInstallManifest(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(binary) error = %v", err)
}
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
if err != nil {
t.Fatalf("InstallManifest() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var manifest NativeHostManifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if manifest.Path != binaryPath {
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
}
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
}
}
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
t.Parallel()
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
if err != nil {
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
}
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
}
}
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
t.Parallel()
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
"mjlnpdomnblnbblhacolncflebbgafhj",
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
})
if err != nil {
t.Fatalf("ManifestSet() error = %v", err)
}
want := []string{
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
}
if !slices.Equal(manifest.AllowedOrigins, want) {
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
}
}
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
got, err := DiscoverInstalledExtensionIDsInRoot(root)
if err != nil {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
}
want := []string{
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
}
if !slices.Equal(got, want) {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
}
}
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", filepath.Join(tmp, "home"))
appDir := filepath.Join(tmp, "app")
if err := os.MkdirAll(appDir, 0o755); err != nil {
t.Fatalf("MkdirAll(appDir) error = %v", err)
}
appBinaryPath := filepath.Join(appDir, "keepassgo")
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
}
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
}
home := filepath.Join(tmp, "home")
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
}
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
}
type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse
err error
matchesErr error
credentialErr error
statusCalls int
}
func writeExtensionManifest(t *testing.T, path, name string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
}
data, err := json.Marshal(map[string]string{"name": name})
if err != nil {
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", path, err)
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", path, err)
}
valuesAny, ok := manifest[field]
if !ok {
t.Fatalf("manifest %q missing field %q", path, field)
}
valuesRaw, ok := valuesAny.([]any)
if !ok {
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
}
values := make([]string, 0, len(valuesRaw))
for _, raw := range valuesRaw {
text, ok := raw.(string)
if !ok {
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
}
values = append(values, text)
}
if !slices.Contains(values, want) {
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
}
}
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
f.statusCalls++
if f.err != nil {
return nil, f.err
}
if f.status == nil {
return &keepassgov1.GetSessionStatusResponse{}, nil
}
return f.status, nil
}
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
if f.matchesErr != nil {
return nil, f.matchesErr
}
if f.err != nil {
return nil, f.err
}
return f.matches, nil
}
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
if f.credentialErr != nil {
return nil, f.credentialErr
}
if f.err != nil {
return nil, f.err
}
if f.credential == nil {
return &keepassgov1.GetBrowserCredentialResponse{}, nil
}
return f.credential, nil
}
-73
View File
@@ -1,73 +0,0 @@
package browserbridge
import (
"context"
"fmt"
"net"
"strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
type GRPCClient struct {
client keepassgov1.VaultServiceClient
}
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
conn, err := req.Connection(grpcAddr)
if err != nil {
return nil, nil, nil, err
}
return Dial(ctx, conn)
}
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
normalized, err := normalizeConnection(conn)
if err != nil {
return nil, nil, nil, err
}
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
if err != nil {
return nil, nil, nil, err
}
target := endpoint
if network == "unix" {
target = "passthrough:///" + endpoint
}
grpcConn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial(network, endpoint)
}),
)
if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
}
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
}
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
}
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
PageUrl: strings.TrimSpace(pageURL),
})
if err != nil {
return nil, err
}
return resp.GetMatches(), nil
}
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
Id: strings.TrimSpace(entryID),
PageUrl: strings.TrimSpace(pageURL),
})
}
@@ -1,210 +0,0 @@
package browserbridge
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
)
const browserExtensionName = "KeePassGO Browser"
type extensionManifestMetadata struct {
Name string `json:"name"`
}
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
path := strings.TrimSpace(appBinaryPath)
if path == "" {
var err error
path, err = os.Executable()
if err != nil {
return "", fmt.Errorf("resolve app executable: %w", err)
}
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("app executable path is required")
}
if filepath.Base(path) == "keepassgo-browser-bridge" {
return path, nil
}
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
resolved, err := exec.LookPath("keepassgo-browser-bridge")
if err == nil {
return resolved, nil
}
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
}
func EnsureNativeHostManifests(appBinaryPath string) error {
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
if err != nil {
return err
}
var errs []error
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
}
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
ids, err := DiscoverInstalledExtensionIDs(browser)
if err != nil {
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
continue
}
if len(ids) == 0 {
continue
}
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
}
}
return errors.Join(errs...)
}
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
root, err := defaultBrowserProfileRoot(browser)
if err != nil {
return nil, err
}
return DiscoverInstalledExtensionIDsInRoot(root)
}
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
base := strings.TrimSpace(root)
if base == "" {
return nil, fmt.Errorf("browser profile root is required")
}
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
paths, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("glob browser extensions: %w", err)
}
ids := make(map[string]struct{}, len(paths))
for _, path := range paths {
ok, err := isKeePassGOExtensionManifest(path)
if err != nil {
return nil, err
}
if !ok {
continue
}
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
if strings.TrimSpace(id) == "" {
continue
}
ids[id] = struct{}{}
}
out := make([]string, 0, len(ids))
for id := range ids {
out = append(out, id)
}
slices.Sort(out)
return out, nil
}
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
if err != nil {
return "", err
}
path := strings.TrimSpace(outputPath)
if path == "" {
path, err = DefaultManifestPath(browser)
if err != nil {
return "", err
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("create native host manifest dir: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", fmt.Errorf("encode native host manifest: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("write native host manifest: %w", err)
}
return path, nil
}
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath)
if path == "" {
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
}
switch browser {
case BrowserFirefox:
return Manifest(browser, path, "")
case BrowserChrome, BrowserChromium:
ids := normalizedExtensionIDs(extensionIDs)
if len(ids) == 0 {
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
}
origins := make([]string, 0, len(ids))
for _, id := range ids {
origins = append(origins, "chrome-extension://"+id+"/")
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedOrigins: origins,
}, nil
default:
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
}
}
func defaultBrowserProfileRoot(browser Browser) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch browser {
case BrowserChrome:
return filepath.Join(home, ".config", "google-chrome"), nil
case BrowserChromium:
return filepath.Join(home, ".config", "chromium"), nil
default:
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
}
}
func isKeePassGOExtensionManifest(path string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
}
var metadata extensionManifestMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
}
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
}
func normalizedExtensionIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, raw := range ids {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
slices.Sort(out)
return out
}
-66
View File
@@ -1,66 +0,0 @@
package grpcaddr
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const socketName = "keepassgo-grpc.sock"
func Default(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
return "127.0.0.1:47777"
}
return "unix://" + DefaultSocketPath()
}
func DefaultSocketPath() string {
return filepath.Join(runtimeDir(), "keepassgo", socketName)
}
func runtimeDir() string {
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
return dir
}
if runtime.GOOS != "windows" {
uid := strconv.Itoa(os.Getuid())
runUserDir := filepath.Join("/run/user", uid)
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
return runUserDir
}
}
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
}
func Parse(raw string) (network, endpoint string, err error) {
value := strings.TrimSpace(raw)
switch {
case value == "":
return "", "", fmt.Errorf("gRPC address is required")
case strings.EqualFold(value, "off"):
return "", "", nil
case strings.HasPrefix(value, "unix://"):
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
if path == "" {
return "", "", fmt.Errorf("unix gRPC socket path is required")
}
return "unix", path, nil
case strings.HasPrefix(value, "tcp://"):
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
if addr == "" {
return "", "", fmt.Errorf("tcp gRPC address is required")
}
return "tcp", addr, nil
case strings.HasPrefix(value, "/"):
return "unix", value, nil
default:
return "tcp", value, nil
}
}
-48
View File
@@ -1,48 +0,0 @@
package grpcaddr
import (
"path/filepath"
"runtime"
"testing"
)
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix default is not expected on windows")
}
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
got := Default("linux")
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
if got != want {
t.Fatalf("Default() = %q, want %q", got, want)
}
}
func TestParse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNetwork string
wantEnd string
}{
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNetwork, gotEnd, err := Parse(tt.input)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
}
})
}
}
-31
View File
@@ -1,31 +0,0 @@
package vaultview
import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string {
if !hasGroup(model.Groups, []string{KeepassRoot}) {
return ""
}
return KeepassRoot
}
func hasGroup(groups [][]string, path []string) bool {
for _, group := range groups {
if len(group) != len(path) {
continue
}
match := true
for i := range group {
if group[i] != path[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
-26
View File
@@ -1,26 +0,0 @@
package vaultview
import (
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"Recycle Bin"},
},
}
if got := HiddenRoot(model); got != "keepass" {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
}
}
-427
View File
@@ -1,427 +0,0 @@
package vaultview
import (
"slices"
"git.julianfamily.org/keepassgo/internal/vault"
)
const KeepassRoot = "keepass"
const TemplatesRoot = "Templates"
// View projects the physical vault model into a logical tree for a specific
// product surface.
type View interface {
ChildGroups(path []string) []string
EntriesInPath(path []string) []vault.Entry
EntriesUnderPath(path []string) []vault.Entry
ToPhysicalPath(path []string) []string
FromPhysicalPath(path []string) []string
ToPhysicalEntry(entry vault.Entry) vault.Entry
FromPhysicalEntry(entry vault.Entry) vault.Entry
}
// Vault returns the physical datastore view.
func Vault(model vault.Model) View {
return physicalView{model: model}
}
// VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group.
func VaultRoot(model vault.Model) View {
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
}
// VaultTemplates returns the logical templates view rooted at the physical
// Templates storage group.
func VaultTemplates(model vault.Model) View {
return templatesView{model: model}
}
// VaultRecycleBin returns the logical recycle-bin view.
func VaultRecycleBin(model vault.Model) View {
return recycleBinView{model: model}
}
type physicalView struct {
model vault.Model
}
func (v physicalView) ChildGroups(path []string) []string {
return v.model.ChildGroups(path)
}
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesInPath(path))
}
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesUnderPath(path))
}
func (v physicalView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
type prefixedView struct {
model vault.Model
root string
rooted bool
}
func (v prefixedView) ChildGroups(path []string) []string {
return v.model.ChildGroups(v.ToPhysicalPath(path))
}
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return []string{v.root}
}
return append([]string{v.root}, clonePath(path)...)
}
func (v prefixedView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return nil
}
if path[0] != v.root {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
out := make([]vault.Entry, 0, len(entries))
for _, entry := range entries {
out = append(out, v.FromPhysicalEntry(entry))
}
return out
}
type recycleBinView struct {
model vault.Model
}
type templatesView struct {
model vault.Model
}
func (v templatesView) ChildGroups(path []string) []string {
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.Templates {
if len(path) > len(entry.Path) {
continue
}
physical := entry.Path
if len(physical) > 0 && physical[0] == TemplatesRoot {
physical = physical[1:]
}
if len(path) > len(physical) {
continue
}
if !slices.Equal(physical[:len(path)], path) {
continue
}
item := cloneEntry(entry)
item.Path = clonePath(physical)
for i := range item.History {
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
}
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v templatesView) ToPhysicalPath(path []string) []string {
if len(path) == 0 {
return []string{TemplatesRoot}
}
return append([]string{TemplatesRoot}, clonePath(path)...)
}
func (v templatesView) FromPhysicalPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] != TemplatesRoot {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v recycleBinView) ChildGroups(path []string) []string {
return childGroups(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.RecycleBin {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
out = append(out, cloneEntry(entry))
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v recycleBinView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func childGroups(entries []vault.Entry, path []string) []string {
return groupChildren(nil, entries, path)
}
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
for _, groupPath := range groupPaths {
if len(path) > len(groupPath) {
continue
}
if !slices.Equal(groupPath[:len(path)], path) {
continue
}
if len(groupPath) == len(path) {
continue
}
group := groupPath[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, cloneEntry(entry))
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func cloneEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = cloneEntry(entries[i])
}
return out
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Path = clonePath(entry.Path)
entry.Tags = slices.Clone(entry.Tags)
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
if entry.Attachments != nil {
attachments := make(map[string][]byte, len(entry.Attachments))
for key, value := range entry.Attachments {
attachments[key] = slices.Clone(value)
}
entry.Attachments = attachments
}
if len(entry.History) != 0 {
history := make([]vault.Entry, len(entry.History))
for i := range entry.History {
history[i] = cloneEntry(entry.History[i])
}
entry.History = history
}
return entry
}
func clonePath(path []string) []string {
if len(path) == 0 {
return nil
}
return slices.Clone(path)
}
func templateGroupPaths(model vault.Model) [][]string {
var out [][]string
for _, group := range model.Groups {
if len(group) == 0 || group[0] != TemplatesRoot {
continue
}
out = append(out, clonePath(group[1:]))
}
return out
}
func usesTopLevelRoot(model vault.Model, root string) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return root == KeepassRoot
}
return groupsUseRoot(model.Groups, root) ||
entriesUseRoot(model.Entries, root) ||
entriesUseRoot(model.Templates, root) ||
entriesUseRoot(model.RecycleBin, root)
}
func groupsUseRoot(groups [][]string, root string) bool {
for _, group := range groups {
if len(group) > 0 && group[0] == root {
return true
}
}
return false
}
func entriesUseRoot(entries []vault.Entry, root string) bool {
for _, entry := range entries {
if len(entry.Path) > 0 && entry.Path[0] == root {
return true
}
}
return false
}
-139
View File
@@ -1,139 +0,0 @@
package vaultview
import (
"slices"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security"},
{"Recycle Bin"},
},
}
view := VaultRoot(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
}
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
t.Parallel()
model := vault.Model{
RecycleBin: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
},
}
view := VaultRecycleBin(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
Groups: [][]string{
{"Templates"},
{"Templates", "Infra"},
{"Templates", "Web"},
{"keepass"},
},
}
view := VaultTemplates(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
}
gotEntries := view.EntriesInPath([]string{"Web"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
}
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
}
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
}
}
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
},
}
view := Vault(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
}
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
}
}

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