Compare commits

..

93 Commits

Author SHA1 Message Date
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
108 changed files with 9138 additions and 26354 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"
-7
View File
@@ -1,9 +1,2 @@
build/
*.apk
/keepassgo
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:
enable:
- errcheck
- gocyclo
- gosimple
- govet
- ineffassign
- staticcheck
- unused
linters-settings:
gocyclo:
min-complexity: 15
issues:
exclude-rules:
- path: _test\.go
linters:
- gocyclo
-63
View File
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are:
`go-code-review`,
`go-concurrency`,
@@ -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.
- The stacked phone layout is the current preferred phone direction.
- 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
@@ -118,20 +102,6 @@ These features are product requirements, not “nice to have” ideas.
- 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.
- 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
@@ -142,14 +112,6 @@ These features are product requirements, not “nice to have” ideas.
implement the minimum code to satisfy them,
verify with `go test ./...` and relevant lint checks,
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.
- If blocked, state the blocker concretely and stop only at that point.
@@ -158,39 +120,14 @@ These features are product requirements, not “nice to have” ideas.
- Plan for direct KDBX support.
- Plan for direct WebDAV-based workflows.
- 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
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- 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.
- 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.
+2 -3
View File
@@ -12,7 +12,6 @@ Environment:
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
@@ -29,12 +28,12 @@ Installed machine prerequisites expected by this repo:
The repo tracks `gogio` as a Go tool, so the build runs through:
```sh
go tool gogio -target android ./cmd/keepassgo ...
go tool gogio -target android ...
```
The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png`
- `assets/keepassgo-icon.png`
Note:
+4 -42
View File
@@ -5,28 +5,11 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
ANDROID_MIN_SDK ?= 28
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 :=
ifneq ($(strip $(SIGNKEY)),)
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
endif
ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild
apk: android/keepassgo-android.jar
.PHONY: apk
apk:
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -41,30 +24,9 @@ apk: android/keepassgo-android.jar
go tool gogio -target android \
-buildmode exe \
-appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \
$(GOGIO_SIGN_FLAGS) \
-o $(APK_OUT) \
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \
-icon internal/assets/keepassgo-icon.png \
./cmd/keepassgo
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
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)"
-icon assets/keepassgo-icon.png \
.
+2 -27
View File
@@ -38,34 +38,9 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
Desktop build:
```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`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`.
@@ -89,7 +64,7 @@ go get -tool gioui.org/cmd/gogio@latest
Package:
```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.
-141
View File
@@ -6,132 +6,6 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met.
## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish.
### 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
These should remain in the main user flow rather than being hidden behind a settings gear.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
keep recent vault selection visually obvious and clearly tappable.
- Local open flow:
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
- Local open flow:
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
- Local open flow:
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
- Remote open flow:
break the remote form into clearer sections such as `Location` and `Authentication`.
- Remote open flow:
make recent remote connections easier to scan with a friendlier label than raw URL and path.
- Locked screen:
show clear vault identity and target summary so the user knows what is being unlocked.
- Entries screen:
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
- Entries screen:
make breadcrumbs compress more aggressively on phone.
- Entries screen:
improve entry-row hierarchy and selected-state contrast.
- Entries screen:
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
- Group navigation:
make the distinction between root, current group, and child groups more obvious.
- Group navigation:
separate navigation controls from group-management controls more clearly.
- Entry detail:
tighten field spacing and reduce unnecessary whitespace.
- Entry detail:
group password reveal and copy actions more clearly.
- Entry detail:
make attachments more visible and actionable.
- Entry edit:
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
- Entry edit:
make add/remove affordances for custom fields more visually obvious.
- Entry edit:
make generated-password draft state more explicit before save.
- Recycle bin:
make it visually distinct from normal entry browsing.
- API tokens:
give token list, token detail, and policy editing a clearer dedicated management surface.
- API tokens:
make policy rows easier to scan by separating effect, operation, and resource visually.
- API audit:
improve empty-state guidance and provide quick filtering by token, decision, and operation.
- Synchronize:
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
fix Android local-open ANR behavior before deeper mobile polish.
- Autofill UX:
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
### Settings Gear Candidates
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
- Vault security:
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
- Vault security:
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
- Remote preferences:
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
- UI preferences:
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
- Autofill behavior:
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
- Sync defaults:
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
- Notification preferences:
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
- Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
- Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
@@ -575,21 +449,6 @@ Exit criteria:
- Focus and accessibility states are visible and intentional.
- `go test ./...` passes.
### Segment 21: Accessibility Fill Generalization
Scope:
- Extend Android accessibility-based fill beyond the current Chrome demo path.
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
Exit criteria:
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
- `go test ./...` passes.
### Segment 17: UI Completion And Error Surfaces
Scope:
-47
View File
@@ -1,47 +0,0 @@
<service
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
android:exported="true"
android:label="KeePassGO Autofill"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data
android:name="android.autofill"
android:resource="@xml/keepassgo_autofill_service" />
</service>
<service
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
android:exported="true"
android:label="KeePassGO Accessibility Fill"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
<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>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:settingsActivity="" />
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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,334 +0,0 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillCacheStore() {
}
static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return null;
}
List<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
if (entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
private static File findCacheFile(Context context) {
List<File> candidates = new ArrayList<>();
File filesDir = context.getFilesDir();
if (filesDir != null) {
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
}
File baseDir = context.getDataDir();
if (baseDir != null) {
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
return candidate;
}
}
Log.i(TAG, "no autofill cache file in " + candidates);
return null;
}
private static List<Entry> readEntries(File cacheFile) throws IOException {
List<Entry> entries = new ArrayList<>();
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("entries".equals(name)) {
reader.beginArray();
while (reader.hasNext()) {
entries.add(readEntry(reader));
}
reader.endArray();
} else {
reader.skipValue();
}
}
reader.endObject();
}
return entries;
}
private static Entry readEntry(JsonReader reader) throws IOException {
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "title":
title = nextString(reader);
break;
case "username":
username = nextString(reader);
break;
case "password":
password = nextString(reader);
break;
case "url":
url = nextString(reader);
break;
case "host":
host = normalizeHost(nextString(reader));
break;
case "targets":
reader.beginArray();
while (reader.hasNext()) {
targets.add(nextString(reader));
}
reader.endArray();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(title, username, password, host, url, targets);
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
static final class Entry {
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
Entry(String title, String username, String password, String host, String url, List<String> targets) {
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
}
}
}
@@ -1,195 +0,0 @@
package org.julianfamily.keepassgo;
import android.accessibilityservice.AccessibilityService;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
public final class KeePassGOAccessibilityService extends AccessibilityService {
private static final String TAG = "KeePassGOA11y";
private String lastFilledSignature = "";
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
try {
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null) {
return;
}
CharSequence packageName = root.getPackageName();
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
return;
}
ChromeForm form = inspectChrome(root);
if (form == null || form.passwordField == null) {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.url);
return;
}
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
if (signature.equals(lastFilledSignature)) {
return;
}
boolean filled = false;
if (form.usernameField != null) {
filled |= setNodeText(form.usernameField, entry.username);
}
filled |= setNodeText(form.passwordField, entry.password);
if (filled) {
lastFilledSignature = signature;
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
}
} catch (Exception err) {
Log.e(TAG, "accessibility fill failed", err);
}
}
@Override
public void onInterrupt() {
Log.i(TAG, "accessibility service interrupted");
}
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm();
walk(root, editables, form);
if (form.url.isEmpty()) {
return null;
}
if (form.passwordField == null) {
for (AccessibilityNodeInfo node : editables) {
if (isPasswordNode(node)) {
form.passwordField = node;
break;
}
}
}
if (form.usernameField == null && form.passwordField != null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
if (isUsernameNode(node)) {
form.usernameField = node;
break;
}
}
if (form.usernameField == null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
form.usernameField = node;
break;
}
}
}
return form;
}
private static void walk(
AccessibilityNodeInfo node,
List<AccessibilityNodeInfo> editables,
ChromeForm form
) {
if (node == null) {
return;
}
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
CharSequence text = node.getText();
if (text != null) {
form.url = text.toString().trim();
}
}
if (node.isEditable()) {
editables.add(node);
if (form.passwordField == null && isPasswordNode(node)) {
form.passwordField = node;
} else if (form.usernameField == null && isUsernameNode(node)) {
form.usernameField = node;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
walk(child, editables, form);
}
}
}
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
if (node.isPassword()) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
String lowerTerm = term.toLowerCase();
return containsLower(node.getHintText(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|| containsLower(node.getPaneTitle(), lowerTerm);
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
if (node == null || !node.isEditable() || value == null) {
return false;
}
Bundle args = new Bundle();
args.putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
value
);
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
}
private static String nodeKey(AccessibilityNodeInfo node) {
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null) {
return viewID.toString();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return String.valueOf(node.getUniqueId());
}
CharSequence hint = node.getHintText();
if (hint != null) {
return hint.toString();
}
return String.valueOf(node.hashCode());
}
private static final class ChromeForm {
String url = "";
AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField;
}
}
@@ -1,215 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.assist.AssistStructure;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
import android.service.autofill.FillCallback;
import android.service.autofill.FillContext;
import android.service.autofill.FillRequest;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveRequest;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import java.util.List;
public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill";
private static final String APP_SCHEME = "androidapp://";
@Override
public void onConnected() {
super.onConnected();
Log.i(TAG, "service connected");
}
@Override
public void onDisconnected() {
Log.i(TAG, "service disconnected");
super.onDisconnected();
}
@Override
public void onFillRequest(
FillRequest request,
CancellationSignal cancellationSignal,
FillCallback callback
) {
try {
Log.i(TAG, "fill request flags=" + request.getFlags());
List<FillContext> contexts = request.getFillContexts();
if (contexts.isEmpty()) {
Log.i(TAG, "fill request had no contexts");
callback.onSuccess(null);
return;
}
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields();
ParsedTarget target = parseWindow(structure, fields);
Log.i(
TAG,
"parsed target=" + target.matchTarget
+ " package=" + target.packageName
+ " usernameId=" + fields.usernameId
+ " passwordId=" + fields.passwordId
);
if (fields.passwordId == null) {
Log.i(TAG, "no password field found");
callback.onSuccess(null);
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
if (entry == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
Log.e(TAG, "fill request failed", err);
callback.onFailure(err.getMessage());
}
}
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
Log.i(TAG, "save request");
callback.onSuccess();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
for (int i = 0; i < windowCount; i++) {
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
String next = parseNode(root, fields);
if (!next.isEmpty()) {
domain = next;
}
}
String packageName = "";
if (structure.getActivityComponent() != null) {
packageName = structure.getActivityComponent().getPackageName();
}
if (!domain.isEmpty()) {
return new ParsedTarget(domain, packageName);
}
if (packageName != null && !packageName.isEmpty()) {
return new ParsedTarget(APP_SCHEME + packageName, packageName);
}
return new ParsedTarget("", "");
}
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
String domain = "";
if (node.getWebDomain() != null) {
domain = node.getWebDomain();
}
AutofillId id = node.getAutofillId();
if (id != null) {
if (fields.passwordId == null && isPasswordNode(node)) {
fields.passwordId = id;
} else if (fields.usernameId == null && isUsernameNode(node)) {
fields.usernameId = id;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
String childDomain = parseNode(node.getChildAt(i), fields);
if (!childDomain.isEmpty()) {
domain = childDomain;
}
}
return domain;
}
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
int inputType = node.getInputType();
int variation = inputType & InputType.TYPE_MASK_VARIATION;
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
int autofillType = node.getAutofillType();
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
return false;
}
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
String lowerTerm = term.toLowerCase();
String[] hints = node.getAutofillHints();
if (hints != null) {
for (String hint : hints) {
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
return true;
}
}
}
if (containsLower(node.getHint(), lowerTerm)
|| containsLower(node.getIdEntry(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)) {
return true;
}
return false;
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static final class ParsedFields {
AutofillId usernameId;
AutofillId passwordId;
}
private static final class ParsedTarget {
final String matchTarget;
final String packageName;
ParsedTarget(String matchTarget, String packageName) {
this.matchTarget = matchTarget;
this.packageName = packageName;
}
}
}
@@ -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();
}
}
+13 -13
View File
@@ -7,26 +7,26 @@ import (
"strings"
"sync"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
)
type DirtyProvider func() bool
type Host struct {
server *Server
grpcServer *grpc.Server
listener net.Listener
lifecycle lifecycleBackend
dirty DirtyProvider
mu sync.Mutex
lastModel vault.Model
started bool
listenAddr string
server *Server
grpcServer *grpc.Server
listener net.Listener
lifecycle lifecycleBackend
dirty DirtyProvider
mu sync.Mutex
lastModel vault.Model
started bool
listenAddr string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
@@ -5,10 +5,10 @@ import (
"net"
"testing"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"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"
)
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
+8 -8
View File
@@ -10,15 +10,15 @@ import (
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
+116 -116
View File
@@ -10,14 +10,14 @@ import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
),
},
})
defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
}
}
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "rustyryan",
Username: "jjulian",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "vault-console",
Id: "git-server",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "surveillance-console",
Title: "Surveillance Console",
Id: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
Url: "https://surveillance.crew.example.invalid",
Url: "https://lights.julianfamily.org",
Fields: map[string]string{
"X-Role": "lights-admin",
},
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
}
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
}
}
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Id: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
Url: "https://www.dynadot.com",
Fields: map[string]string{
"Environment": "staging",
},
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
}
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "vault-console",
Id: "git-server",
HistoryIndex: 0,
})
if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
Content: uploaded,
}); err != nil {
t.Fatalf("UploadAttachment() error = %v", err)
}
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
})
if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
}); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err)
}
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1053,64 +1053,64 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-0",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "git-server-h1",
Title: "Git Server",
Username: "joejulian",
Password: "token-0",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
},
Path: []string{"Root", "Internet"},
},
{
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
),
},
Templates: []vault.Entry{
{
ID: "website-login",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Fields: map[string]string{
"Environment": "prod",
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
Tags: []string{"template", "web"},
Path: []string{"Templates"},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
),
},
},
}
Templates: []vault.Entry{
{
ID: "website-login",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Fields: map[string]string{
"Environment": "prod",
},
Tags: []string{"template", "web"},
Path: []string{"Templates"},
},
},
}
return newTestClientForModel(t, model)
}
@@ -9,7 +9,7 @@ import (
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/apitokens"
)
var (
@@ -22,20 +22,20 @@ var (
type Outcome string
const (
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
)
type Request struct {
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
RequestedAt time.Time
}
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/apitokens"
)
type EventType string
@@ -16,9 +16,6 @@ const (
EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected"
)
@@ -4,7 +4,7 @@ import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
@@ -47,22 +47,3 @@ func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
}
}
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
@@ -12,7 +12,7 @@ import (
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/vault"
)
const (
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/vault"
)
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
@@ -7,10 +7,10 @@ import (
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
type Section string
@@ -26,7 +26,6 @@ const (
SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
SectionAbout Section = "about"
)
type CurrentSession interface {
@@ -62,7 +61,6 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
@@ -133,52 +131,6 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
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) {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -350,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
}
if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil
return model.EntriesInPath(s.CurrentPath), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
@@ -423,7 +375,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit, SectionAbout:
case SectionAPITokens, SectionAPIAudit:
return nil
default:
return slices.Clone(model.Entries)
@@ -770,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil
}
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
@@ -880,66 +820,6 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
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)
s.Dirty = true
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)
s.Dirty = true
return nil
}
func (s *State) CreateGroup(name string) error {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -957,27 +837,6 @@ func (s *State) CreateGroup(name string) error {
return nil
}
func (s *State) MoveCurrentGroup(parent []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
current := append([]string(nil), s.CurrentPath...)
if err := model.MoveGroup(current, parent); err != nil {
return err
}
session.Replace(model)
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -6,11 +6,11 @@ import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
CurrentPath: []string{"Joe", "Internet"},
}
got, err := state.VisibleEntries()
@@ -39,39 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
}
}
@@ -164,71 +133,6 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
}
}
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.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",
},
{
ID: "archive-webdav",
Name: "Archive Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/archive.kdbx",
},
},
},
},
}
got, err := state.RemoteProfiles()
if err != nil {
t.Fatalf("RemoteProfiles() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
}
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
}
}
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
},
},
},
}
got, err := state.RemoteCredentialEntries()
if err != nil {
t.Fatalf("RemoteCredentialEntries() error = %v", err)
}
if len(got) != 3 {
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
}
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
}
}
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Parallel()
@@ -236,14 +140,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "lights",
}
got, err := state.VisibleEntries()
@@ -251,43 +155,8 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
}
}
func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
},
},
},
CurrentPath: []string{"Crew"},
SearchQuery: "missing",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
@@ -414,8 +283,8 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
},
@@ -483,13 +352,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Crew"},
CurrentPath: []string{"Joe"},
}
got, err := state.ChildGroups()
@@ -497,8 +366,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
@@ -535,19 +404,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
CurrentPath: []string{"Joe", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
}
if err := state.ToggleVisibleIndex(1); err != nil {
@@ -589,14 +458,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.DeleteSelectedEntry(); err != nil {
@@ -611,8 +480,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
}
if !state.Dirty {
@@ -626,7 +495,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
@@ -635,7 +504,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("vault-console"); err != nil {
if err := state.RestoreEntry("git-server"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -644,8 +513,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
}
if !state.Dirty {
@@ -665,27 +534,27 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
}
entry := vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-1",
URL: "https://vault.crew.example.invalid",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
}
if !state.Dirty {
@@ -717,11 +586,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
})
if err != nil {
@@ -732,16 +601,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
if got := state.SelectedEntryID; got != "dynadot" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
if len(got) != 1 || got[0].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
}
if !state.Dirty {
@@ -808,24 +677,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
}
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
if state.SelectedEntryID != "git-server-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -837,13 +706,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -856,8 +725,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
}
if !state.Dirty {
@@ -871,19 +740,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
ID: "git-server",
Title: "Git Server",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -927,7 +796,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -956,7 +825,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -1007,7 +876,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -1029,199 +898,20 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
}
}
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{
model: vault.Model{
Entries: []vault.Entry{
{
ID: "remote-creds-1",
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",
},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: SyncModeAutomaticOnOpenSave,
}, vault.MasterKey{Password: "correct horse battery staple"})
if err != nil {
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
}
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
}
if got := sess.remoteClient.Username; got != "linuscaldwell" {
t.Fatalf("remote client username = %q, want linuscaldwell", got)
}
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
}
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after bound remote open")
}
}
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{model: vault.Model{}}
state := State{Session: sess}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "missing-profile",
CredentialEntryID: "remote-creds-1",
}, vault.MasterKey{Password: "correct horse battery staple"})
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
}
}
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
binding, err := state.ConfigureRemoteBinding(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 !state.Dirty {
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
}
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
}
if got := len(sess.model.RemoteProfiles); got != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
}
credentials, err := sess.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)
}
}
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
t.Parallel()
state := State{Session: stubSession{model: vault.Model{}}}
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
})
if err == nil {
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
}
}
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{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",
}},
}}
state := State{Session: sess}
err := state.RemoveRemoteBinding(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
}
if got := len(sess.model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.Lock(); err != nil {
@@ -1244,7 +934,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
locked: true,
@@ -1262,8 +952,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
}
}
@@ -1292,7 +982,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{
Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
SearchQuery: "git",
}
@@ -1328,7 +1018,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel()
state := State{
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
ErrorMessage: "previous error",
}
@@ -1372,7 +1062,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
state.EnterGroup("Internet")
@@ -1390,13 +1080,13 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
state.NavigateToPath([]string{"Root", "Security Office"})
state.NavigateToPath([]string{"Root", "Home Assistant"})
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
@@ -1429,8 +1119,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
@@ -1452,35 +1142,14 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after CreateGroup")
}
}
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{
Session: session,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Infrastructure / Prod"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
}
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -1526,8 +1195,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
@@ -1541,14 +1210,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
state.NavigateToPath([]string{"Root", "Security Office"})
state.NavigateToPath([]string{"Root", "Home Assistant"})
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
@@ -1557,45 +1226,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
}
}
func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
session := &mutableStubSession{model: model}
state := State{
Session: session,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
}
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
@@ -1603,7 +1241,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1631,7 +1269,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1657,7 +1295,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1683,7 +1321,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1701,7 +1339,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1727,7 +1365,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1755,8 +1393,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
},
}
}
@@ -1797,17 +1435,15 @@ func (s *saveableStubSession) Save() error {
}
type lifecycleStubSession struct {
createCalls int
model vault.Model
openPath string
saveAsPath string
remoteClient webdav.Client
remotePath string
changedKey vault.MasterKey
createCalls int
openPath string
saveAsPath string
remotePath string
changedKey vault.MasterKey
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
return s.model, nil
return vault.Model{}, nil
}
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
@@ -1825,32 +1461,11 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
return nil
}
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteClient = client
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
s.remotePath = path
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key
return nil

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

+2 -6
View File
@@ -13,10 +13,9 @@ const (
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "internal/assets/keepassgo-icon.png"
DefaultIconPath = "assets/keepassgo-icon.png"
)
type Config struct {
@@ -26,7 +25,6 @@ type Config struct {
AppID string
APKOut string
Version string
Ldflags string
MinSDK string
TargetSDK string
IconPath string
@@ -40,7 +38,6 @@ func DefaultConfig() Config {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
@@ -52,13 +49,12 @@ func (c Config) GogioArgs() []string {
"-target", "android",
"-buildmode", "exe",
"-appid", c.AppID,
"-ldflags", c.Ldflags,
"-o", c.APKOut,
"-version", c.Version,
"-minsdk", c.MinSDK,
"-targetsdk", c.TargetSDK,
"-icon", c.IconPath,
"./cmd/keepassgo",
".",
}
}
+1 -4
View File
@@ -15,13 +15,12 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath,
"./cmd/keepassgo",
".",
}
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
@@ -53,7 +52,6 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
@@ -79,7 +77,6 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
@@ -5,7 +5,7 @@ import (
systemclipboard "github.com/atotto/clipboard"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/vault"
)
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/vault"
)
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
},
},
}
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target
want string
}{
{name: "username", target: TargetUsername, want: "dannyocean"},
{name: "username", target: TargetUsername, want: "joejulian"},
{name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
}
for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter
service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil {
if err := service.Copy(model, "git-server", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err)
}
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
}
err = service.Copy(model, "vault-console", Target("unsupported"))
err = service.Copy(model, "git-server", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
}
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Password: "token-1"},
{ID: "git-server", Password: "token-1"},
},
}
err := service.Copy(model, "vault-console", TargetPassword)
err := service.Copy(model, "git-server", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
-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
- search focus
- new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go).
## Current screen-reader boundary
-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.
+1 -4
View File
@@ -4,7 +4,6 @@ go 1.26
require (
gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -17,7 +16,6 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,7 +72,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -193,7 +190,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
+2 -8
View File
@@ -44,10 +44,6 @@ 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/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -230,8 +226,6 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -677,8 +671,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-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)
}
}
-90
View File
@@ -1,90 +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.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
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),
strings.Join(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, " "))
}
File diff suppressed because it is too large Load Diff
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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.currentPath = append([]string(nil), u.state.CurrentPath...)
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))
}
File diff suppressed because it is too large Load Diff
-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
-191
View File
@@ -1,191 +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/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 {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
return "127.0.0.1:47777"
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate
ui.clipboardWriter = 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.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
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)
}
}
}
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")
}
-436
View File
@@ -1,436 +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"`
}
type debugSettings struct {
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
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,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
}
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.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
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
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.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
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)
}),
)
}
-296
View File
@@ -1,296 +0,0 @@
package autofillcache
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
Host string `json:"host"`
Targets []string `json:"targets,omitempty"`
Path []string `json:"path,omitempty"`
}
type File struct {
UpdatedAt string `json:"updatedAt"`
Entries []Entry `json:"entries"`
}
type MatchStatus string
const (
MatchStatusNone MatchStatus = ""
MatchStatusFound MatchStatus = "found"
MatchStatusAmbiguous MatchStatus = "ambiguous"
MatchStatusMissing MatchStatus = "missing"
)
type MatchResult struct {
Status MatchStatus `json:"status"`
Entry Entry `json:"entry,omitempty"`
}
func Match(cache File, webURL string) (Entry, bool) {
result := Resolve(cache, webURL)
return result.Entry, result.Status == MatchStatusFound
}
func Resolve(cache File, webURL string) MatchResult {
target := normalizeURL(webURL)
if target.host == "" {
return MatchResult{Status: MatchStatusMissing}
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entryMatchesHost(entry, target.host) {
exactHost = append(exactHost, entry)
continue
}
if entryMatchesParentHost(entry, target.host) {
parentHost = append(parentHost, entry)
}
}
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
return result
}
return chooseEntry(target, parentHost)
}
func Build(model vault.Model, now time.Time) File {
entries := make([]Entry, 0, len(model.Entries))
for _, item := range model.Entries {
targets := collectTargets(item)
host := normalizeHost(item.URL)
if host == "" {
for _, target := range targets {
host = normalizeHost(target)
if host != "" {
break
}
}
}
if host == "" {
continue
}
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
continue
}
entries = append(entries, Entry{
ID: item.ID,
Title: item.Title,
Username: item.Username,
Password: item.Password,
URL: item.URL,
Host: host,
Targets: targets,
Path: append([]string(nil), item.Path...),
})
}
return File{
UpdatedAt: now.UTC().Format(time.RFC3339),
Entries: entries,
}
}
func Write(path string, model vault.Model, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(Build(model, now), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func Clear(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeHost(raw string) string {
return normalizeURL(raw).host
}
type normalizedTarget struct {
host string
path string
url string
}
func normalizeURL(raw string) normalizedTarget {
value := strings.TrimSpace(raw)
if value == "" {
return normalizedTarget{}
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return normalizedTarget{}
}
host := strings.TrimSpace(parsed.Hostname())
path := cleanPath(parsed.EscapedPath())
return normalizedTarget{
host: strings.ToLower(host),
path: path,
url: strings.ToLower(host) + path,
}
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
path = strings.TrimRight(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
switch len(entries) {
case 0:
return MatchResult{Status: MatchStatusMissing}
case 1:
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
}
exact := make([]Entry, 0)
bestPrefixLen := -1
bestPrefix := make([]Entry, 0)
for _, entry := range entries {
exactMatch, prefixLen := bestTargetMatch(entry, target)
if exactMatch {
exact = append(exact, entry)
continue
}
if prefixLen <= 0 {
continue
}
switch {
case prefixLen > bestPrefixLen:
bestPrefixLen = prefixLen
bestPrefix = []Entry{entry}
case prefixLen == bestPrefixLen:
bestPrefix = append(bestPrefix, entry)
}
}
if len(exact) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
}
if len(exact) > 1 {
return MatchResult{Status: MatchStatusAmbiguous}
}
if len(bestPrefix) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
}
if len(bestPrefix) == 0 {
return MatchResult{Status: MatchStatusMissing}
}
return MatchResult{Status: MatchStatusAmbiguous}
}
func collectTargets(item vault.Entry) []string {
seen := make(map[string]struct{})
targets := make([]string, 0, 1+len(item.Fields))
appendTarget := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
targets = append(targets, value)
}
appendTarget(item.URL)
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
upper := strings.ToUpper(strings.TrimSpace(key))
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
appendTarget(item.Fields[key])
}
}
return targets
}
func entryTargets(entry Entry) []normalizedTarget {
values := entry.Targets
if len(values) == 0 {
values = []string{entry.URL}
}
targets := make([]normalizedTarget, 0, len(values))
for _, value := range values {
target := normalizeURL(value)
if target.host == "" {
continue
}
targets = append(targets, target)
}
return targets
}
func entryMatchesHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host == host {
return true
}
}
return false
}
func entryMatchesParentHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
return true
}
}
return false
}
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
bestPrefixLen := -1
for _, candidate := range entryTargets(entry) {
if candidate.url == target.url {
return true, 0
}
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
bestPrefixLen = pathLen
}
}
}
return false, bestPrefixLen
}
-339
View File
@@ -1,339 +0,0 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
got := Build(vault.Model{
Entries: []vault.Entry{
{
ID: "one",
Title: "Chrome Test",
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"},
},
{
ID: "two",
Title: "No Password",
Username: "joe",
URL: "https://example.com",
},
{
ID: "three",
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
},
},
},
}, now)
if len(got.Entries) != 2 {
t.Fatalf("entry count = %d, want 2", len(got.Entries))
}
if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
}
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
}
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
t.Fatalf("updatedAt = %q", got.UpdatedAt)
}
}
func TestWriteAndClear(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "autofill-cache.json")
model := vault.Model{
Entries: []vault.Entry{
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
},
}
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var got File
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("cache entries = %#v", got.Entries)
}
if err := Clear(path); err != nil {
t.Fatalf("Clear() error = %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cache path still exists, stat err = %v", err)
}
}
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Primary Login",
Username: "first",
Password: "secret1",
URL: "https://10.0.2.2:8443/login/",
Host: "10.0.2.2",
},
{
ID: "two",
Title: "Alt Login",
Username: "second",
Password: "secret2",
URL: "https://10.0.2.2:8443/alt/",
Host: "10.0.2.2",
},
},
}
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Host A",
Username: "first",
Password: "secret1",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
},
}
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
}
}
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Admin Login",
Username: "admin",
Password: "secret1",
URL: "https://example.com/admin",
Host: "example.com",
},
{
ID: "two",
Title: "Shared Login A",
Username: "shared-a",
Password: "secret2",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
{
ID: "three",
Title: "Shared Login B",
Username: "shared-b",
Password: "secret3",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
},
}
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
}
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
}
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
t.Fatalf("Resolve(missing) = %#v, want missing", got)
}
}
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Generic Login",
Username: "generic",
Password: "secret1",
URL: "https://example.com/",
Host: "example.com",
},
{
ID: "two",
Title: "Admin Login",
Username: "admin",
Password: "secret2",
URL: "https://example.com/admin",
Host: "example.com",
},
},
}
got, ok := Match(cache, "https://example.com/admin/login")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird/login",
Host: "org.mozilla.thunderbird",
},
},
}
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird Primary",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
{
ID: "two",
Title: "Thunderbird Secondary",
Username: "other-user",
Password: "secret2",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
},
}
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
}
}
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://account.blinknetwork.com",
Host: "account.blinknetwork.com",
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
},
},
}
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://blinknetwork.com",
Host: "blinknetwork.com",
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
},
},
}
got, ok := Match(cache, "https://account.blinknetwork.com")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
+3474
View File
File diff suppressed because it is too large Load Diff
+3497
View File
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
pkgbase = keepassgo-git
pkgdesc = KeePass-compatible password manager written in Go
pkgver = r165.1c72a50
pkgrel = 1
url = https://git.julianfamily.org/joejulian/keepassgo
arch = x86_64
arch = aarch64
license = custom
makedepends = git
makedepends = go
makedepends = pkgconf
depends = glibc
depends = hicolor-icon-theme
depends = libx11
depends = libxcursor
depends = libxfixes
depends = libxkbcommon
depends = libxkbcommon-x11
depends = mesa
depends = wayland
provides = keepassgo
conflicts = keepassgo
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
sha256sums = SKIP
pkgname = keepassgo-git
@@ -1,59 +0,0 @@
pkgname=keepassgo-git
pkgver=@PKGVER@
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
printf '%s\n' "@REPO_DIR@"
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
local app_version
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 internal/assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 internal/assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -1,10 +0,0 @@
[Desktop Entry]
Type=Application
Name=KeePassGO
Comment=KeePass-compatible password manager
Exec=keepassgo
Icon=keepassgo
Terminal=false
Categories=Utility;Security;
Keywords=keepass;password;vault;kdbx;
StartupNotify=true
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import mimetypes
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict:
data = None
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def request_no_content(method: str, url: str, token: str) -> None:
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
method=method,
)
with urllib.request.urlopen(req):
return
def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}"
try:
return request_json("GET", tag_url, token)
except urllib.error.HTTPError as err:
if err.code != 404:
raise
payload = {
"tag_name": tag,
"name": tag,
"draft": False,
"prerelease": False,
}
return request_json("POST", f"{base}/releases", token, payload)
def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
assets = release.get("assets", [])
for asset in assets:
if asset.get("name") == path.name:
request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token)
query = urllib.parse.urlencode({"name": path.name})
url = f"{base}/releases/{release['id']}/assets?{query}"
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
req = urllib.request.Request(
url,
data=path.read_bytes(),
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": content_type,
},
method="POST",
)
with urllib.request.urlopen(req):
return
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("artifacts", nargs="+")
args = parser.parse_args()
paths = [pathlib.Path(p) for p in args.artifacts]
missing = [str(p) for p in paths if not p.is_file()]
if missing:
print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr)
return 1
release = get_or_create_release(args.server, args.repo, args.token, args.tag)
for path in paths:
upload_asset(args.server, args.repo, args.token, release, path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -4,15 +4,14 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
var (
@@ -33,33 +32,6 @@ type Manager struct {
remoteVersion webdav.Version
}
type PreparedLocalOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
}
type PreparedRemoteOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Client webdav.Client
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
}
type PreparedUnlock struct {
Model vault.Model
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings {
return vault.DetectSecuritySettings(m.config)
}
@@ -93,10 +65,6 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
}
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
func (m *Manager) IsLocked() bool {
return m.locked
}
@@ -106,11 +74,23 @@ func (m *Manager) IsRemote() bool {
}
func (m *Manager) Open(path string, key vault.MasterKey) error {
prepared, err := PrepareLocalOpen(path, key)
content, err := os.ReadFile(path)
if err != nil {
return err
return fmt.Errorf("read %s: %w", path, err)
}
m.ApplyPreparedLocalOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
m.model = model
m.config = config
m.path = path
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
return nil
}
@@ -127,11 +107,25 @@ func (m *Manager) Save() error {
}
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
prepared, err := PrepareRemoteOpen(client, path, key)
content, version, err := client.Open(path)
if err != nil {
return err
return fmt.Errorf("open remote %s: %w", path, err)
}
m.ApplyPreparedRemoteOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("decode remote %s: %w", path, err)
}
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.remoteClient = &client
m.remotePath = path
m.remoteVersion = version
return nil
}
@@ -178,18 +172,6 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil {
@@ -283,101 +265,19 @@ func (m *Manager) Lock() error {
}
func (m *Manager) Unlock(key vault.MasterKey) error {
prepared, err := PrepareUnlock(m.encoded, key)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
if err != nil {
return err
return fmt.Errorf("unlock vault: %w", err)
}
m.ApplyPreparedUnlock(prepared)
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.locked = false
return nil
}
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
content, err := os.ReadFile(path)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
content, version, err := client.Open(path)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
RemoteVersion: version,
}, nil
}
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
if err != nil {
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.path = prepared.Path
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = &prepared.Client
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
var (
model vault.Model
@@ -921,17 +821,6 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil
}
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key)
if err == nil {
@@ -10,8 +10,8 @@ import (
"path/filepath"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"github.com/tobischo/gokeepasslib/v3"
w "github.com/tobischo/gokeepasslib/v3/wrappers"
)
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
}
}
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Crew", "Internet"},
URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Joe", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "dannyocean",
Username: "joejulian",
Password: "token-2",
URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Crew", "eMail"},
URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Joe", "eMail"},
},
},
}, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
sess.Replace(current)
if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
}
}
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
}
}
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-1",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
if len(got) != 1 || got[0].Title != "Git Server" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
}
var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
},
},
},
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID,
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: current.Entries[0].Path,
})
sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "remote-token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "local-token-2",
URL: "https://vault.crew.example.invalid/security/badges",
URL: "https://git.julianfamily.org/settings/tokens",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
}
if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
@@ -953,63 +953,6 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
}
}
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
@@ -1021,10 +964,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -1033,10 +976,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
@@ -1078,10 +1021,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -1090,10 +1033,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-remote",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if 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)))
}
return appearance
}
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, 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 {
switch field {
case detailFieldID:
return "ID"
case detailFieldTitle:
return "Title"
case detailFieldUsername:
return "Username"
case detailFieldPassword:
return "Password"
case detailFieldURL:
return "URL"
case detailFieldPath:
return "Path"
case detailFieldTags:
return "Tags"
case detailFieldPasswordProfile:
return "Password Profile"
case detailFieldNotes:
return "Notes"
case detailFieldFields:
return "Custom Fields"
case detailFieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
+665
View File
@@ -0,0 +1,665 @@
package main
import (
"fmt"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens"
)
func apiOperations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationManageVault,
}
}
func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens()
if err != nil {
return nil
}
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiTokenClicks) < len(tokens) {
u.apiTokenClicks = make([]widget.Clickable, len(tokens))
}
return tokens
}
filtered := make([]apitokens.Token, 0, len(tokens))
for _, token := range tokens {
haystack := strings.ToLower(strings.Join([]string{
token.Name,
token.ClientName,
token.ID,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, token)
}
}
if len(u.apiTokenClicks) < len(filtered) {
u.apiTokenClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func (u *ui) selectedAPIToken() (apitokens.Token, bool) {
tokens, err := u.state.APITokens()
if err != nil {
return apitokens.Token{}, false
}
for _, token := range tokens {
if token.ID == strings.TrimSpace(u.state.SelectedEntryID) {
return token, true
}
}
return apitokens.Token{}, false
}
func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
if count <= 0 {
u.apiPolicyRemoves = nil
return nil
}
if len(u.apiPolicyRemoves) == count {
return u.apiPolicyRemoves
}
clicks := make([]widget.Clickable, count)
copy(clicks, u.apiPolicyRemoves)
u.apiPolicyRemoves = clicks
return clicks
}
func (u *ui) loadSelectedAPITokenIntoEditor() {
token, ok := u.selectedAPIToken()
if !ok {
u.apiTokenSecret = ""
u.apiTokenName.SetText("")
u.apiTokenClientName.SetText("")
u.apiTokenExpiresAt.SetText("")
u.apiTokenDisabled.Value = false
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText(strings.Join(u.displayPath(), " / "))
u.apiPolicyEntryID.SetText("")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.ensureAPIPolicyRemoveClickables(0)
return
}
u.apiTokenName.SetText(token.Name)
u.apiTokenClientName.SetText(token.ClientName)
if token.ExpiresAt != nil {
u.apiTokenExpiresAt.SetText(token.ExpiresAt.UTC().Format(time.RFC3339))
} else {
u.apiTokenExpiresAt.SetText("")
}
u.apiTokenDisabled.Value = token.Disabled
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
func (u *ui) issueAPITokenAction() error {
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token, secret, err := u.state.IssueAPIToken(strings.TrimSpace(u.apiTokenName.Text()), strings.TrimSpace(u.apiTokenClientName.Text()), expiresAt, u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) saveAPITokenAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token.Name = strings.TrimSpace(u.apiTokenName.Text())
token.ClientName = strings.TrimSpace(u.apiTokenClientName.Text())
token.ExpiresAt = expiresAt
token.Disabled = u.apiTokenDisabled.Value
return u.state.UpsertAPIToken(token)
}
func (u *ui) rotateAPITokenAction() error {
token, secret, err := u.state.RotateAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) disableAPITokenAction() error {
if err := u.state.DisableAPIToken(strings.TrimSpace(u.state.SelectedEntryID)); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) revokeAPITokenAction() error {
if err := u.state.RevokeAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now()); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) deleteAPITokenAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no API token selected")
}
if err := u.state.DeleteAPIToken(id); err != nil {
return err
}
u.state.SelectedEntryID = ""
u.loadSelectedAPITokenIntoEditor()
return nil
}
func parseAPITokenExpiry(text string) (*time.Time, error) {
value := strings.TrimSpace(text)
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, fmt.Errorf("expiration must use RFC3339, for example 2026-04-01T15:04:05Z")
}
return &parsed, nil
}
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
value := apitokens.Operation(strings.TrimSpace(text))
for _, operation := range apiOperations() {
if operation == value {
return value, nil
}
}
return "", fmt.Errorf("unknown API operation %q", text)
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil {
return err
}
rule := apitokens.PolicyRule{
Operation: operation,
Effect: apitokens.EffectDeny,
}
if u.apiPolicyAllow.Value {
rule.Effect = apitokens.EffectAllow
}
u.apiPolicyGroupScope = u.apiPolicyGroupScopeW.Value
if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" {
return fmt.Errorf("entry id is required for entry scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
}
if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil {
return nil
}
events := u.auditLog.Events()
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiAuditClicks) < len(events) {
u.apiAuditClicks = make([]widget.Clickable, len(events))
}
return events
}
filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events {
haystack := strings.ToLower(strings.Join([]string{
string(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, event)
}
}
if len(u.apiAuditClicks) < len(filtered) {
u.apiAuditClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func formatAPIPolicyRule(rule apitokens.PolicyRule) string {
scope := strings.Join(rule.Resource.Path, " / ")
if rule.Resource.Kind == apitokens.ResourceEntry {
scope = "entry " + rule.Resource.EntryID
}
return strings.TrimSpace(strings.Join([]string{
strings.ToUpper(string(rule.Effect)),
string(rule.Operation),
scope,
}, " "))
}
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if strings.Join(rule.Resource.Path, "\x00") == strings.Join(target.Resource.Path, "\x00") {
return true
}
}
return false
}
func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, token apitokens.Token) layout.Dimensions {
for click.Clicked(gtx) {
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = ""
u.loadSelectedAPITokenIntoEditor()
}
selected := strings.TrimSpace(u.state.SelectedEntryID) == token.ID
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := 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(16), token.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Non-expiring"
if token.ExpiresAt != nil {
text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339)
}
lbl := material.Label(u.theme, unit.Sp(12), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
return layout.Background{}.Layout(gtx, fill(selectedColor), func(gtx layout.Context) layout.Dimensions {
paintBar := layout.Stack{}.Layout
_ = paintBar
return layout.Dimensions{Size: size}
})
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, event apiaudit.Event) layout.Dimensions {
for click.Clicked(gtx) {
u.selectedAuditIndex = idx
}
selected := u.selectedAuditIndex == idx
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := 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(15), string(event.Type))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.TrimSpace(event.ClientName))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
tokens := u.apiTokens()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(tokens) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
})
}),
)
}
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Local gRPC audit history"
if strings.TrimSpace(u.grpcAddress) != "" {
text = "Local gRPC audit history at " + u.grpcAddress
}
lbl := material.Label(u.theme, unit.Sp(13), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(events), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiAuditRow(gtx, &u.apiAuditClicks[i], i, events[i])
})
}),
)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
if ok {
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Token")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditor(u.theme, "Name", &u.apiTokenName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
},
}
if ok {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token ID", token.ID),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)),
)
if token.RevokedAt != nil {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)),
)
}
}
if strings.TrimSpace(u.apiTokenSecret) != "" {
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
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.copyAPITokenSecret, "Copy Secret")
}),
)
})
},
)
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
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.saveAPIToken, "Save Token")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete")
}),
)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
)
if ok && len(token.Policies) > 0 {
for i, rule := range token.Policies {
index := i
ruleText := formatAPIPolicyRule(rule)
rows = append(rows,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), ruleText)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
)
}
} else {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
},
)
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Audit")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
text := "No audit events yet."
if len(events) > 0 {
text = fmt.Sprintf("%d recent security events recorded.", len(events))
}
lbl := material.Label(u.theme, unit.Sp(14), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
}
if u.selectedAuditIndex >= 0 && u.selectedAuditIndex < len(events) {
event := events[u.selectedAuditIndex]
rows = append(rows,
layout.Spacer{Height: unit.Dp(12)}.Layout,
detailLine(u.theme, "Type", string(event.Type)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token", event.TokenName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Client", event.ClientName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Operation", string(event.Operation)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Resource", formatAuditResource(event.Resource)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Message", event.Message),
)
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func stringOps(ops []apitokens.Operation) []string {
out := make([]string, 0, len(ops))
for _, op := range ops {
out = append(out, string(op))
}
return out
}
func formatAuditResource(resource apitokens.Resource) string {
if resource.Kind == apitokens.ResourceEntry {
return "entry " + resource.EntryID
}
if len(resource.Path) == 0 {
return "/"
}
return strings.Join(resource.Path, " / ")
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"image"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
)
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if u.mode != "phone" {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.mode == "phone" {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
@@ -1,4 +1,4 @@
package appui
package main
import (
"fmt"
@@ -8,9 +8,9 @@ import (
"strings"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault"
)
func (u *ui) attachmentInput() (string, []byte, error) {
@@ -41,7 +41,6 @@ func (u *ui) attachmentInput() (string, []byte, error) {
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
@@ -176,7 +175,6 @@ func (u *ui) saveEntryAction() error {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -231,7 +229,6 @@ func (u *ui) saveTemplateAction() error {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -254,14 +251,6 @@ func (u *ui) createGroupAction() error {
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
@@ -411,7 +400,6 @@ func (u *ui) generatePasswordAction() error {
return err
}
u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil
}
@@ -493,27 +481,3 @@ func marshalFields(fields map[string]string) string {
}
return strings.Join(lines, "\n")
}
func (u *ui) clearGeneratedPasswordDraft() {
u.generatedPasswordDraft = false
}
func (u *ui) attachmentActionSummary() string {
items := u.selectedAttachmentItems()
if len(items) == 0 {
return "No attachments yet. Add one below to store a file with this entry."
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "Select an attachment above, then replace it, export it, or remove it below."
}
for _, item := range items {
if item.Name == name {
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
}
}
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
}
+639
View File
@@ -0,0 +1,639 @@
package main
import (
"fmt"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/appstate"
)
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
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.showLocalLifecycle, "Local Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password")
box.Color = accentColor
return box.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.recentRemoteList),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.recentVaultList),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: aes256, chacha20", &u.securityCipher, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: aes-kdf, argon2", &u.securityKDF, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault")
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openVault, "Open Vault")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.createVault, "New Vault")
}),
)
}),
)
}
func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
if len(u.recentVaults) == 0 {
return layout.Dimensions{}
}
if len(u.recentVaultClicks) < len(u.recentVaults) {
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENTLY OPENED")
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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.recentVaults)*2)
for i, path := range u.recentVaults {
index := i
label := path
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.recentVaultClicks[index], label)
}))
if i < len(u.recentVaults)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
)
}
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
if len(u.recentRemotes) == 0 {
return layout.Dimensions{}
}
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.recentRemotes)*2)
for i, record := range u.recentRemotes {
index := i
label := record.BaseURL + " / " + record.Path
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.recentRemoteClicks[index], label)
}))
if i < len(u.recentRemotes)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
)
}
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
items := u.selectedAttachmentItems()
if len(items) == 0 {
lbl := material.Label(u.theme, unit.Sp(13), "No attachments on this entry.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(items)*2)
for i, item := range items {
index := i
itemName := item.Name
label := fmt.Sprintf("%s (%d B)", itemName, item.Size)
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.attachmentClicks[index].Clicked(gtx) {
u.attachmentName.SetText(itemName)
}
return tonedButton(gtx, u.theme, &u.attachmentClicks[index], label)
}))
if i < len(items)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}
func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "CUSTOM FIELDS")
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(11), "Add key/value pairs. Changes are only saved when you save the entry.")
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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(u.customFieldKeys)*2)
for i := range u.customFieldKeys {
index := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.removeCustomFields[index].Clicked(gtx) {
u.removeCustomFieldRow(index)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(0.38, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(0.52, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "-")
}),
)
}))
if i < len(u.customFieldKeys)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.addCustomField.Clicked(gtx) {
u.appendCustomFieldRow("", "")
}
return tonedButton(gtx, u.theme, &u.addCustomField, "+")
}),
)
}
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
deletable, deleteReason := u.currentGroupDeletionState()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
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 tonedButton(gtx, u.theme, &u.createGroup, "Create Group")
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.displayPath()) == 0 {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group")
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !deletable || u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Empty Group")
}),
)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.displayPath()) == 0 {
return layout.Dimensions{}
}
if u.deleteGroupPendingConfirmation() {
lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("Delete %q? This group is empty, and the deletion cannot be undone.", strings.Join(u.displayPath(), " / ")))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
if deletable || strings.TrimSpace(deleteReason) == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), deleteReason)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.confirmDeleteGroup, "Confirm Delete")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelDeleteGroup, "Cancel")
}),
)
}),
)
}
func (u *ui) groupControlsSection(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
if u.groupControlsHidden {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(u.groupControls),
)
}
func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
return u.toggleGroupControls.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon
if u.groupControlsHidden {
icon = u.expandMoreIcon
}
if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">")
if !u.groupControlsHidden {
lbl.Text = "v"
}
lbl.Color = accentColor
return lbl.Layout(gtx)
}
return icon.Layout(gtx, accentColor)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Group Tools")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), u.passwordProfileOptionsText())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(120))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(u.customFieldEditorPanel),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
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 tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelEdit, "Cancel")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionTemplates {
return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template")
}
return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Generate Password only updates the form. Nothing is persisted until you save.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ATTACHMENTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(u.attachmentList),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
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 tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Attachment")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment")
}),
)
}),
)
}
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorWithFocus(th, label, editor, sensitive, false)
}
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false)
}
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, click *widget.Clickable, buttonLabel string, sensitive bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(420)) {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, label, editor, sensitive)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, th, click, buttonLabel)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return labeledEditor(th, label, editor, sensitive)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, th, click, buttonLabel)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
}
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}),
)
}
func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensions {
icon := u.eyeIcon
desc := "Show master password"
mask := rune('•')
if u.showPassword {
icon = u.eyeOffIcon
desc = "Hide master password"
mask = 0
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "MASTER PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
restore := u.masterPassword.Mask
u.masterPassword.Mask = mask
defer func() { u.masterPassword.Mask = restore }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
ed := material.Editor(u.theme, &u.masterPassword, "Master Password")
return ed.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.togglePassword, icon, desc)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
func labeledEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
}),
)
}
}
func labeledMultilineEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
minHeight unit.Dp,
) layout.Widget {
return 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), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
if min := gtx.Dp(minHeight); gtx.Constraints.Min.Y < min {
gtx.Constraints.Min.Y = min
}
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
}),
)
}
}
+30 -94
View File
@@ -1,46 +1,32 @@
package appui
package main
import (
"fmt"
"strconv"
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/internal/appstate"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/appstate"
)
type focusID string
type detailField = editormodel.Field
type detailField string
const (
focusSearch focusID = "search"
detailFieldID = editormodel.FieldID
detailFieldTitle = editormodel.FieldTitle
detailFieldUsername = editormodel.FieldUsername
detailFieldPassword = editormodel.FieldPassword
detailFieldURL = editormodel.FieldURL
detailFieldPath = editormodel.FieldPath
detailFieldTags = editormodel.FieldTags
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
detailFieldNotes = editormodel.FieldNotes
detailFieldFields = editormodel.FieldFields
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
detailFieldID detailField = "id"
detailFieldTitle detailField = "title"
detailFieldUsername detailField = "username"
detailFieldPassword detailField = "password"
detailFieldURL detailField = "url"
detailFieldPath detailField = "path"
detailFieldTags detailField = "tags"
detailFieldPasswordProfile detailField = "password-profile"
detailFieldNotes detailField = "notes"
detailFieldFields detailField = "fields"
detailFieldHistoryIndex detailField = "history-index"
)
func breadcrumbFocusID(index int) focusID {
@@ -55,81 +41,19 @@ func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
}
if u.isVaultLocked() && name == key.NameReturn {
u.startUnlockAction()
u.runAction("unlock vault", u.unlockAction)
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.startOpenRemoteAction()
u.runAction("open remote vault", u.openRemoteAction)
} else {
u.startOpenVaultAction()
u.runAction("open vault", u.openVaultAction)
}
return true
}
@@ -412,7 +336,19 @@ func (u *ui) focusedDetailIndex() int {
}
func detailFocusOrder() []detailField {
return editormodel.FocusOrder()
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
}
func canonicalFocusID(id focusID) focusID {
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/clipboard"
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
)
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Original note",
Path: []string{"Root", "Internet"},
},
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
}
model.UpsertEntry(Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Updated note",
Path: []string{"Root", "Internet"},
})
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "surveillance-console",
Title: "Surveillance Console",
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
}
if err := model.DeleteEntry("surveillance-console"); err != nil {
if err := model.DeleteEntry("ha-codex"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
}
if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
}
}
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{
RecycleBin: []Entry{
{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "token-3",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
if err := model.RestoreEntry("bellagio"); err != nil {
if err := model.RestoreEntry("dynadot"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
}
if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
Notes: "Current note",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "vault-console-history-1",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server-history-1",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
Notes: "Previous note",
Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
},
}
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err)
}
+3 -40
View File
@@ -3,7 +3,6 @@ package vault
import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
@@ -24,10 +23,9 @@ type KDBXConfig struct {
var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles"
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID"
)
func LoadKDBX(r io.Reader, password string) (Model, error) {
@@ -51,7 +49,6 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = credentials
db.Content.Meta = gokeepasslib.NewMetaData()
db.Content.Meta.CustomData = customDataForModel(model)
db.Content.Root = &gokeepasslib.RootData{}
if config != nil && config.Header != nil {
db.Header = cloneHeader(config.Header)
@@ -328,7 +325,6 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
for _, group := range db.Content.Root.Groups {
appendGroupEntries(&model, db, group, nil)
}
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
return model, &KDBXConfig{
Header: cloneHeader(db.Header),
@@ -336,39 +332,6 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
}, nil
}
func customDataForModel(model Model) []gokeepasslib.CustomData {
if len(model.RemoteProfiles) == 0 {
return nil
}
content, err := json.Marshal(model.RemoteProfiles)
if err != nil {
return nil
}
return []gokeepasslib.CustomData{{
Key: remoteProfilesKey,
Value: string(content),
}}
}
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
if meta == nil {
return nil
}
for _, item := range meta.CustomData {
if item.Key != remoteProfilesKey {
continue
}
var profiles []RemoteProfile
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
return nil
}
return profiles
}
return nil
}
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
switch {
case key.Password != "" && len(key.KeyFileData) > 0:

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