Compare commits

..

2 Commits

Author SHA1 Message Date
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
158 changed files with 6308 additions and 47379 deletions
-122
View File
@@ -1,122 +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` for debug validation, or `make apk-release` when validating production signing behavior.
3. If the build 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
```
Typical local release build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
```
## 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 the intended target: `make apk` for debug validation or `make apk-release` for release-signing validation.
- 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
-115
View File
@@ -1,115 +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-release
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
- `ship it` must use the dedicated release keystore flow, not Gio's implicit debug or temporary signing path.
- The default local release-signing paths are:
`~/.config/keepassgo/android-release.keystore`
`~/.config/keepassgo/android-release.pass`
- If those files are unavailable, stop and fix signing instead of shipping a differently signed APK.
### 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`.
-196
View File
@@ -1,196 +0,0 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches:
- main
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
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: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- 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: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- 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
mkdir -p build/ci-signing
signkey_path="$(pwd)/build/ci-signing/android-release.keystore"
signpass_path="$(pwd)/build/ci-signing/android-release.pass"
trap 'rm -f -- "$signkey_path" "$signpass_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
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"
+1 -10
View File
@@ -1,10 +1 @@
build/
*.apk
/keepassgo
/keepassgo-browser-bridge
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
packaging/archlinux/keepassgo-git/pkg/
packaging/archlinux/keepassgo-git/src/
packaging/archlinux/keepassgo-git/keepassgo/
gio-keepass-mock
-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
-69
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,24 +102,9 @@ These features are product requirements, not “nice to have” ideas.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- 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
- Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices:
@@ -143,14 +112,6 @@ These features are product requirements, not “nice to have” ideas.
implement the minimum code to satisfy them,
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.
@@ -159,39 +120,9 @@ 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` provided by `actions/setup-java` with Temurin 25.
- 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.
-79
View File
@@ -1,79 +0,0 @@
# Android Build
Build the APK with:
```sh
make apk
```
Build the release-signed APK with:
```sh
make apk-release
```
`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
If the host does not have a working Java 25 install, it falls back to the
repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
with Java 25. CI provisions Java 25 directly in the build job so release builds
use that same local path instead of nested Docker.
`make apk` remains a developer build path and may use Gio's default debug or
ephemeral signing behavior if no explicit signing key is provided.
`make apk-release` is the production-signing path and fails unless a dedicated
release keystore and password file are present.
Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`.
- `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.
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
- `SIGNPASS_FILE` provides the signing password by file instead of a command-line argument.
- `RELEASE_SIGNKEY` overrides the release keystore path used by `make apk-release`.
- `RELEASE_SIGNPASS_FILE` overrides the password file path used by `make apk-release`.
Default release-signing paths:
- `~/.config/keepassgo/android-release.keystore`
- `~/.config/keepassgo/android-release.pass`
Installed machine prerequisites expected by this repo:
- `android-sdk-cmdline-tools-latest`
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
- a working Java 25 JDK install for `make apk-local`, or Docker for `make apk`
The repo tracks `gogio` as a Go tool, and the local build runs through:
```sh
go tool gogio -target android ./cmd/keepassgo ...
```
The release target wraps `make apk` and injects explicit signing credentials so
local release builds and CI use the same stable key without echoing the release
password in build logs.
The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png`
Note:
- KeePassGO's documented Android build uses Java 25 locally and in CI.
- If that host setup is unavailable, `make apk` falls back to the Docker image
so the build still runs under Java 25 instead of encoding a newer host JDK as
a requirement.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
pipeline. KeePassGO is pinned to the working Gio line until that regression is
understood upstream.
-139
View File
@@ -1,139 +0,0 @@
ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
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 ?=
SIGNPASS_FILE ?=
RELEASE_SIGNKEY ?= $(HOME)/.config/keepassgo/android-release.keystore
RELEASE_SIGNPASS_FILE ?= $(HOME)/.config/keepassgo/android-release.pass
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
CONTAINER_SIGNKEY_MOUNT :=
CONTAINER_SIGNPASSFILE_MOUNT :=
CONTAINER_SIGN_ARGS :=
ifneq ($(strip $(SIGNKEY)),)
CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro"
CONTAINER_SIGN_ARGS += SIGNKEY="$(abspath $(SIGNKEY))"
endif
ifneq ($(strip $(SIGNPASS)),)
CONTAINER_SIGN_ARGS += SIGNPASS="$(SIGNPASS)"
endif
ifneq ($(strip $(SIGNPASS_FILE)),)
CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(abspath $(SIGNPASS_FILE))):ro"
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
endif
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate
apk:
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
else \
echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \
$(MAKE) apk-container; \
fi
apk-local: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))"
@set -eu; \
if [ -n "$(SIGNPASS_FILE)" ]; then \
test -f "$(SIGNPASS_FILE)" || { echo "SIGNPASS_FILE does not exist: $(SIGNPASS_FILE)"; exit 1; }; \
export GOGIO_SIGNPASS="$$(tr -d '\r\n' < "$(SIGNPASS_FILE)")"; \
test -n "$$GOGIO_SIGNPASS" || { echo "SIGNPASS_FILE is empty: $(SIGNPASS_FILE)"; exit 1; }; \
fi; \
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
JAVA_HOME="$(JAVA_HOME)" \
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
apk-release:
@test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
@test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))" JAVA_HOME="$(JAVA_HOME)"
apk-container: apk-container-image
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; 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; }
docker run --rm \
-u "$$(id -u):$$(id -g)" \
-v "$(CURDIR):$(CURDIR)" \
-w "$(CURDIR)" \
-v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \
-v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \
$(CONTAINER_SIGNKEY_MOUNT) \
$(CONTAINER_SIGNPASSFILE_MOUNT) \
-e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
-e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
-e JAVA_HOME=/opt/java/openjdk \
$(APK_BUILD_IMAGE) \
make apk-local JAVA_HOME=/opt/java/openjdk $(CONTAINER_SIGN_ARGS)
apk-container-image:
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; }
docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk
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
@sh -ec 'tmpdir=$$(mktemp -d); \
trap "rm -rf $$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)"
browser-bridge:
go build ./cmd/keepassgo-browser-bridge
browser-extension-validate:
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
+6 -66
View File
@@ -1,12 +1,11 @@
# KeePassGO
KeePassGO is a Go-based KeePass-compatible password manager targeting desktop first, with future Android support.
KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support.
## Current Capabilities
- KDBX load and save
- password-only, key-file-only, and composite master-key flows through the desktop product UI
- master-key changes for existing vault sessions
- password, key-file, and composite master-key support in the storage layer
- WebDAV-backed open and save support in the session layer
- password generation profiles
- gRPC integration surface for trusted automation
@@ -38,87 +37,28 @@ 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`
- `/usr/bin/keepassgo-browser-bridge`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`.
The repo now has automated tests for the packaging contract:
- default APK build arguments
- required Android SDK / NDK / JDK layout checks
Those are covered by normal test runs:
```bash
go test ./...
```
Install:
```bash
go get -tool gioui.org/cmd/gogio@latest
go install gioui.org/cmd/gogio@latest
```
Package:
```bash
make apk
gogio -target android .
```
`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
available, it falls back to the repo-managed Docker build image, which also
uses Java 25. CI provisions Java 25 directly in the build job so release
packaging follows that same local path. You still need the Android SDK and NDK
installed and configured for real device or release packaging.
Release package:
```bash
make apk-release
```
`make apk-release` is the production-signing path. It requires a dedicated
release keystore at `~/.config/keepassgo/android-release.keystore` and a
password file at `~/.config/keepassgo/android-release.pass`, unless you
override `RELEASE_SIGNKEY` and `RELEASE_SIGNPASS_FILE`.
You will need the Android SDK and NDK installed and configured for real device or release packaging.
## Automation
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
## Browser Extension
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
See [`docs/browser-extension.md`](./docs/browser-extension.md).
+45 -240
View File
@@ -6,229 +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.
- Browser extension:
- 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.
- Synchronize:
define exact local-versus-remote merge semantics for cases where both sides changed, and make the user-facing action names describe the real behavior instead of ambiguous `push`/`pull` labels if those actions perform two-way reconciliation.
- Synchronize:
choose sync wording and defaults that maximize user comprehension and safety, especially around merge, overwrite, conflict, and retry behavior.
- Phone layout:
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.
## Upstream Gap Review
This section tracks explicit feature gaps against the source-level behavior of:
- KeePass 2.57.1
- KeePassHttp
- Keepass2Android
These are not speculative enhancements. They are parity gaps relative to the
stated product requirement to cover the practical feature surface of those
upstream tools where it fits KeePassGO's security model.
### Stage 1
- Android autofill parity/completeness:
close the remaining gaps in Android autofill behavior, including broader
page/app detection coverage, stronger approval and visibility UX, and more
reliable fill behavior across real-world apps and browsers.
- Android fallback fill workflows:
provide a non-autofill fallback comparable in usefulness to KP2A's keyboard
and share-driven workflows for apps and browsers that do not cooperate with
platform autofill.
- Browser extension save/update:
add the browser-side save/update-credential workflow after successful form
submission, not only lookup and fill.
- Search and matching controls:
browser/API result behavior does not yet expose KeePassHttp-style controls
such as best-match-only, scheme matching, and sort preferences as a finished
product surface.
- Unlock-request workflow:
KeePassHttp has an explicit locked-database browser flow; KeePassGO still
needs a polished browser-visible locked/unlock request experience.
- Android share/intents:
browser/app share-driven lookup and open flows comparable to KP2A are not
implemented as a full user workflow.
### Stage 2
- OTP/TOTP:
implement real OTP/TOTP support, including storage conventions compatible
with common KeePass ecosystems and usable display/copy/fill workflows.
- TOTP product surface:
KP2A exposes TOTP directly in entry and list UX; KeePassGO does not.
- Browser-returned field breadth:
KeePassHttp can return string fields for browser consumers; KeePassGO does
not yet have a finished policy and browser UX for rich field return.
- Placeholder and field-reference parity:
KeePass-style placeholder expansion, field references, and related command
and URL override behavior are not implemented as a product surface.
- Offline/work-offline flow:
KP2A has explicit offline/cache-oriented remote-file workflows that are more
mature than KeePassGO's current user-facing remote behavior.
### Stage 3
- Desktop automation:
implement desktop login automation comparable in practical capability to
KeePass auto-type, or replace it with a demonstrably superior workflow that
covers global invocation, selected-entry invocation, window targeting, and
field sequencing.
- Trigger system:
KeePass-style event/condition/action triggers are not implemented.
- Import/export breadth:
KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other
exchange formats is still missing.
- Multi-database lookup:
KeePassHttp can search across all opened databases when configured; KeePassGO
does not yet have an equivalent multi-vault lookup model.
- Remote backend breadth:
KP2A supports far more remote/file backends than KeePassGO currently does;
KeePassGO is still effectively WebDAV-first.
- Plugin/extensibility model:
KeePassGO has integrations, but not a first-class plugin model comparable to
KeePass.
- Plugin ecosystem replacements:
QR transfer, keyboard transport, and related mobile integration equivalents
do not exist in KeePassGO.
- Emergency and recovery utilities:
emergency-sheet/key-file-backup style flows are not implemented.
### Immediate Product Questions
- Desktop automation:
decide whether KeePassGO will implement true auto-type, or a different
security model that still satisfies the practical workflows KeePass users
expect.
- OTP model:
decide the canonical KeePassGO representation for TOTP/HOTP so desktop,
Android, gRPC, and browser workflows can all target the same semantics.
- Remote breadth:
decide which non-WebDAV backends are in product scope after WebDAV.
### 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.
### Segment 1: Application State Ownership
Scope:
@@ -297,6 +74,19 @@ Exit criteria:
- Validation and visible error states exist for missing or invalid key material.
- `go test ./...` passes.
### Segment 5: KDBX Security Settings Preservation
Scope:
- Preserve supported cipher and KDF settings when reopening and saving.
- Surface relevant settings in product-facing docs or UI where appropriate.
- Document unsupported settings explicitly.
Exit criteria:
- Reopen-and-save cycles preserve supported KDBX security settings.
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
- Tests cover settings preservation across save cycles.
- `go test ./...` passes.
### Segment 6: Entry CRUD UI
Scope:
@@ -361,7 +151,7 @@ Exit criteria:
- Tests cover clear/reset transitions.
- `go test ./...` passes.
### Segment 10 (stage 3): Template CRUD UI
### Segment 10: Template CRUD UI
Scope:
- Create template.
@@ -470,21 +260,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:
@@ -502,6 +277,33 @@ Exit criteria:
- UI tests or controller-integrated tests cover these states.
- `go test ./...` passes.
### Segment 18: Desktop Automation Resolution
Scope:
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
- or explicitly finalize the design that secure gRPC supersedes auto-type.
- Keep the decision documented in-repo.
Exit criteria:
- The desktop automation requirement is explicitly resolved in code or docs.
- The chosen approach is documented in `docs/desktop-automation.md`.
- Any implemented behavior is tested.
- `go test ./...` passes.
### Segment 19: Packaging And Runbook
Scope:
- Keep the app runnable from source.
- Document desktop build and run steps.
- Document Android packaging with `gogio`.
- Add icon and metadata placeholders if missing.
Exit criteria:
- `README.md` is accurate for local build, run, and Android packaging guidance.
- Placeholder metadata exists where needed for packaging.
- The app still builds from the repo.
- `go test ./...` passes.
### Segment 20: Regression And Integration Coverage
Scope:
@@ -519,10 +321,11 @@ Exit criteria:
Do not treat the product as complete until all of the following are true:
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
- Segment 1 through Segment 20 are all complete.
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
- KeePassGO supports master password, key file, and composite key workflows in the product.
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
@@ -532,7 +335,9 @@ Do not treat the product as complete until all of the following are true:
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
- KeePassGO exposes password generation profiles through both UI and gRPC.
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
- The desktop automation requirement is explicitly resolved.
- Keyboard-first navigation and common shortcuts exist for major product workflows.
- The UI no longer depends on prototype-only mock behavior for any core workflow.
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes.
- `go tool golangci-lint run ./...` passes.
-66
View File
@@ -1,66 +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>
<activity
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
android:exported="false"
android:label="Search KeePassGO" />
<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="text/plain" />
</intent-filter>
<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" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<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" />
<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,116 +0,0 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.JsonWriter;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
final class AutofillBindingStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillBindingStore() {
}
static String entryIDForTarget(Context context, String rawTarget) {
Bindings bindings = read(context);
return bindings.apps.getOrDefault(normalize(rawTarget), "");
}
static void rememberBinding(Context context, String rawTarget, String entryID) {
String target = normalize(rawTarget);
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
return;
}
Bindings bindings = read(context);
bindings.updatedAt = Instant.now().toString();
bindings.apps.put(target, entryID.trim());
write(context, bindings);
}
private static Bindings read(Context context) {
File path = path(context);
if (!path.isFile()) {
return new Bindings();
}
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
Bindings bindings = new Bindings();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("updatedAt".equals(name)) {
bindings.updatedAt = nextString(reader);
continue;
}
if ("apps".equals(name)) {
reader.beginObject();
while (reader.hasNext()) {
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
}
reader.endObject();
continue;
}
reader.skipValue();
}
reader.endObject();
return bindings;
} catch (IOException err) {
Log.e(TAG, "failed to read autofill bindings", err);
return new Bindings();
}
}
private static void write(Context context, Bindings bindings) {
File path = path(context);
File parent = path.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
return;
}
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
writer.setIndent(" ");
writer.beginObject();
writer.name("updatedAt").value(bindings.updatedAt);
writer.name("apps");
writer.beginObject();
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
writer.name(entry.getKey()).value(entry.getValue());
}
writer.endObject();
writer.endObject();
} catch (IOException err) {
Log.e(TAG, "failed to write autofill bindings", err);
}
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static File path(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
}
private static String normalize(String rawTarget) {
return rawTarget == null ? "" : rawTarget.trim();
}
private static final class Bindings {
String updatedAt = "";
final Map<String, String> apps = new LinkedHashMap<>();
}
}
@@ -1,231 +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.Comparator;
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) {
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return null;
}
return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
}
static Entry findByID(Context context, String entryID) {
if (entryID == null || entryID.trim().isEmpty()) {
return null;
}
for (Entry entry : readEntries(context)) {
if (entryID.equals(entry.id)) {
return entry;
}
}
return null;
}
static List<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return entries;
}
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
entries.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return entries;
}
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
for (Entry entry : entries) {
converted.add(new AutofillTargetMatcher.Entry(
entry.id,
entry.title,
entry.username,
entry.password,
entry.host,
entry.url,
entry.targets,
entry.path
));
}
return converted;
}
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return null;
}
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
}
private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return new ArrayList<>();
}
try {
return readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return new ArrayList<>();
}
}
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 id = "";
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
List<String> path = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "id":
id = nextString(reader);
break;
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;
case "path":
reader.beginArray();
while (reader.hasNext()) {
path.add(nextString(reader));
}
reader.endArray();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(id, title, username, password, host, url, targets, path);
}
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 AutofillTargetMatcher.normalize(raw).host;
}
static final class Entry {
final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
final List<String> path;
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
this.path = new ArrayList<>(path);
}
}
}
@@ -1,24 +0,0 @@
package org.julianfamily.keepassgo;
final class AutofillFallbackTarget {
private static final String APP_SCHEME = "androidapp://";
private AutofillFallbackTarget() {
}
static String resolve(String packageName, String webDomain) {
String domain = trim(webDomain);
if (!domain.isEmpty()) {
return domain;
}
String pkg = trim(packageName);
if (pkg.isEmpty()) {
return "";
}
return APP_SCHEME + pkg;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -1,260 +0,0 @@
package org.julianfamily.keepassgo;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
final class AutofillTargetMatcher {
private AutofillTargetMatcher() {
}
static Entry findBestMatch(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalize(rawTarget);
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);
}
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
Entry direct = findBestMatch(entries, rawTarget);
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
NormalizedTarget target = normalize(rawTarget);
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);
}
}
if (!exactHost.isEmpty()) {
return sortEntries(exactHost);
}
if (!parentHost.isEmpty()) {
return sortEntries(parentHost);
}
return sortEntries(entries);
}
static NormalizedTarget normalize(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String trimmed = raw.trim();
if (trimmed.isEmpty()) {
return new NormalizedTarget("", "", "");
}
String original = trimmed;
String value = trimmed.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 = original.indexOf("://");
if (schemeSep < 0) {
original = "https://" + original;
}
try {
URI uri = 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 = normalize(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);
}
private static List<Entry> sortEntries(List<Entry> entries) {
List<Entry> sorted = new ArrayList<>(entries);
sorted.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return sorted;
}
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;
}
}
static final class Entry {
final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
final List<String> path;
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
this.path = new ArrayList<>(path);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
}
@@ -1,198 +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;
}
ChromeForm form = inspectWindow(root);
if (form == null || form.passwordField == null) {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
return;
}
String signature = form.matchTarget + "|" + 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.matchTarget + " 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 inspectWindow(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm();
walk(root, editables, form);
if (form.matchTarget.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.webDomain = text.toString().trim();
}
}
if (form.packageName.isEmpty()) {
CharSequence packageName = node.getPackageName();
if (packageName != null) {
form.packageName = packageName.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);
}
}
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
}
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 packageName = "";
String webDomain = "";
String matchTarget = "";
AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField;
}
}
@@ -1,182 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillManager;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import android.service.autofill.Dataset;
public final class KeePassGOAutofillPickerActivity extends Activity {
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
private ArrayAdapter<String> adapter;
private String matchTarget = "";
private String packageName = "";
private AutofillId usernameID;
private AutofillId passwordID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
matchTarget = intent.getStringExtra(EXTRA_TARGET);
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
root.setPadding(padding, padding, padding, padding);
TextView title = new TextView(this);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
title.setGravity(Gravity.START);
title.setPadding(0, 0, 0, dp(8));
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
root.addView(title, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
EditText search = new EditText(this);
search.setHint("Search entries");
root.addView(search, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
allEntries.clear();
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
visibleEntries.clear();
visibleEntries.addAll(allEntries);
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
ListView list = new ListView(this);
list.setAdapter(adapter);
list.setOnItemClickListener(this::onEntrySelected);
TextView empty = new TextView(this);
empty.setPadding(0, dp(16), 0, 0);
empty.setText("No KeePassGO entries are available for autofill.");
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
root.addView(empty, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0
);
listParams.weight = 1f;
listParams.topMargin = dp(12);
root.addView(list, listParams);
search.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
filterEntries(s == null ? "" : s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
setContentView(root);
}
private void filterEntries(String rawQuery) {
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
visibleEntries.clear();
if (query.isEmpty()) {
visibleEntries.addAll(allEntries);
} else {
for (AutofillCacheStore.Entry entry : allEntries) {
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
visibleEntries.add(entry);
}
}
}
adapter.clear();
adapter.addAll(labelsFor(visibleEntries));
adapter.notifyDataSetChanged();
}
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
setResult(Activity.RESULT_CANCELED);
finish();
return;
}
AutofillCacheStore.Entry entry = visibleEntries.get(position);
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
}
Dataset.Builder dataset = new Dataset.Builder();
dataset.setId(entry.id);
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
}
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
Intent result = new Intent();
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
setResult(Activity.RESULT_OK, result);
finish();
}
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
List<String> labels = new ArrayList<>(entries.size());
for (AutofillCacheStore.Entry entry : entries) {
labels.add(entryLabel(entry));
}
return labels;
}
private static String entryLabel(AutofillCacheStore.Entry entry) {
StringBuilder label = new StringBuilder();
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
if (entry.username != null && !entry.username.trim().isEmpty()) {
label.append(" (").append(entry.username.trim()).append(")");
}
if (entry.path != null && !entry.path.isEmpty()) {
label.append("").append(String.join(" / ", entry.path));
}
return label.toString();
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -1,275 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.PendingIntent;
import android.app.assist.AssistStructure;
import android.content.Intent;
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 = findBoundOrBestMatch(target.matchTarget);
if (entry == null) {
FillResponse chooser = chooserResponse(target, fields);
if (chooser == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
callback.onSuccess(chooser);
return;
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
FillResponse response = directFillResponse(entry, fields);
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 AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
if (bound != null) {
return bound;
}
}
return AutofillCacheStore.findBestMatch(this, matchTarget);
}
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
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);
dataset.setId(entry.id);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
return new FillResponse.Builder()
.addDataset(dataset.build())
.build();
}
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
if (fields.passwordId == null) {
return null;
}
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
return null;
}
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
target.matchTarget.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
);
AutofillId[] ids;
if (fields.usernameId != null) {
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
} else {
ids = new AutofillId[]{fields.passwordId};
}
return new FillResponse.Builder()
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
.build();
}
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,226 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.ClipData;
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.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt";
private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt";
@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) {
logIntent(intent);
String sharedLookup = resolveSharedLookup(intent);
if (!sharedLookup.isEmpty()) {
try {
persistPendingLookup(sharedLookup);
Log.i(TAG, "queued shared lookup target");
} catch (IOException | RuntimeException err) {
Log.e(TAG, "failed to queue shared lookup target", err);
}
return;
}
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)) {
Uri extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (extraStream != null) {
return extraStream;
}
}
if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (streams != null && !streams.isEmpty()) {
return streams.get(0);
}
}
if (Intent.ACTION_VIEW.equals(action)) {
Uri data = intent.getData();
if (data != null) {
return data;
}
}
ClipData clipData = intent.getClipData();
if (clipData != null && clipData.getItemCount() > 0) {
Uri clipUri = clipData.getItemAt(0).getUri();
if (clipUri != null) {
return clipUri;
}
}
return null;
}
private String resolveSharedLookup(Intent intent) {
if (intent == null) {
return "";
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) {
CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (extraText != null) {
return extraText.toString().trim();
}
}
if (Intent.ACTION_VIEW.equals(action)) {
Uri data = intent.getData();
if (data != null) {
String scheme = data.getScheme();
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
return data.toString();
}
}
}
return "";
}
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);
try (InputStream in = openSharedInputStream(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);
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
private void persistPendingLookup(String lookup) 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_LOOKUP);
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
out.write(lookup.getBytes(StandardCharsets.UTF_8));
}
}
private InputStream openSharedInputStream(Uri uri) throws IOException {
if ("file".equalsIgnoreCase(uri.getScheme())) {
String path = uri.getPath();
if (path == null || path.trim().isEmpty()) {
throw new IOException("file URI is missing a path");
}
return new FileInputStream(new File(path));
}
return getContentResolver().openInputStream(uri);
}
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 logIntent(Intent intent) {
if (intent == null) {
return;
}
Log.i(TAG, "intent action=" + intent.getAction()
+ " type=" + intent.getType()
+ " data=" + intent.getData()
+ " flags=0x" + Integer.toHexString(intent.getFlags()));
ClipData clipData = intent.getClipData();
if (clipData != null) {
Log.i(TAG, "intent clip items=" + clipData.getItemCount());
}
}
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();
}
}
@@ -1,153 +0,0 @@
package org.julianfamily.keepassgo;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
public final class AutofillCacheStoreBehaviorTest {
public static void main(String[] args) {
testFindBestMatchUsesAndroidAppTargets();
testChooserCandidatesCollapseToExactAndroidAppMatch();
testChooserCandidatesStayScopedToExactHostMatches();
testChooserCandidatesStayScopedToParentHostMatches();
}
private static void testFindBestMatchUsesAndroidAppTargets() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2");
if (got == null || !"blink-entry".equals(got.id)) {
throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry");
}
}
private static void testChooserCandidatesCollapseToExactAndroidAppMatch() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2");
if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]");
}
}
private static void testChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-backup",
"Bellagio Backup",
"rustyryan",
"backup-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/admin",
Arrays.asList("https://bellagio.example.invalid/admin"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 2 || !containsIDs(got, "bellagio-primary", "bellagio-backup")) {
throw new AssertionError("chooserCandidates(entries, exact host) = " + describe(got) + ", want only Bellagio entries");
}
}
private static void testChooserCandidatesStayScopedToParentHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-parent",
"Bellagio Parent",
"linuscaldwell",
"chip-stack",
"example.invalid",
"https://example.invalid/login",
Arrays.asList("https://example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 1 || !"bellagio-parent".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, parent host) = " + describe(got) + ", want [bellagio-parent]");
}
}
private static String describe(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return "null";
}
return entry.id;
}
private static String describe(List<AutofillTargetMatcher.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsIDs(List<AutofillTargetMatcher.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
}
@@ -1,30 +0,0 @@
package org.julianfamily.keepassgo;
public final class AutofillFallbackTargetBehaviorTest {
public static void main(String[] args) {
testPrefersWebDomainWhenPresent();
testFallsBackToAndroidAppPackage();
testEmptyWhenNeitherSignalExists();
}
private static void testPrefersWebDomainWhenPresent() {
String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com");
if (!"gitlab.com".equals(got)) {
throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com");
}
}
private static void testFallsBackToAndroidAppPackage() {
String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", "");
if (!"androidapp://com.blinknetwork.mobile2".equals(got)) {
throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2");
}
}
private static void testEmptyWhenNeitherSignalExists() {
String got = AutofillFallbackTarget.resolve("", "");
if (!"".equals(got)) {
throw new AssertionError("resolve(empty) = " + got + ", want empty");
}
}
}
+626
View File
@@ -0,0 +1,626 @@
package api
import (
"context"
"errors"
"maps"
"slices"
"sync"
"strings"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type Server struct {
keepassgov1.UnimplementedVaultServiceServer
mu sync.RWMutex
model vault.Model
locked bool
dirty bool
lifecycle lifecycleBackend
profiles map[string]passwords.Profile
clipboard clipboard.Writer
}
type lifecycleBackend interface {
Current() (vault.Model, error)
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
profiles: profiles,
clipboard: clipboardWriter,
}
}
func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server {
server := NewServer(model, profiles, clipboardWriter)
server.lifecycle = lifecycle
return server
}
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenVaultResponse{}, nil
}
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
client := webdav.Client{
BaseURL: req.GetBaseUrl(),
Username: req.GetUsername(),
Password: req.GetPassword(),
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenRemoteVaultResponse{}, nil
}
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Save(); err != nil {
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
}
s.mu.Lock()
s.dirty = false
s.mu.Unlock()
return &keepassgov1.SaveVaultResponse{}, nil
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.locked = true
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.locked = false
return &keepassgov1.UnlockVaultResponse{}, nil
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := s.model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
}
} else {
entries = s.model.EntriesInPath(req.GetPath())
}
resp := &keepassgov1.ListEntriesResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
}
for _, entry := range entries {
resp.Entries = append(resp.Entries, entryToProto(entry))
}
return resp, nil
}
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
return &keepassgov1.ListGroupsResponse{
Names: s.model.ChildGroups(req.GetPath()),
}, nil
}
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.CreateGroup(req.GetParentPath(), req.GetName())
s.dirty = true
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
}
s.dirty = true
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
s.mu.Lock()
if s.locked {
s.mu.Unlock()
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.UpsertEntry(entry)
s.dirty = true
s.mu.Unlock()
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete entry: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteEntryResponse{}, nil
}
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var restored vault.Entry
for _, entry := range s.model.RecycleBin {
if entry.ID == req.GetId() {
restored = entry
break
}
}
if err := s.model.RestoreEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry: %v", err)
}
s.dirty = true
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
}
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
resp := &keepassgov1.ListEntryHistoryResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
}
for _, historical := range entry.History {
resp.Entries = append(resp.Entries, entryToProto(historical))
}
return resp, nil
}
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
}
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
s.dirty = true
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
resp := &keepassgov1.ListTemplatesResponse{
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
}
for _, template := range s.model.Templates {
resp.Templates = append(resp.Templates, entryToProto(template))
}
return resp, nil
}
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
if req.GetTemplate() == nil {
return nil, status.Error(codes.InvalidArgument, "missing template")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProto(req.GetTemplate())
s.model.UpsertTemplate(entry)
s.dirty = true
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
}
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteTemplate(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteTemplateResponse{}, nil
}
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
if req.GetOverrides() == nil {
return nil, status.Error(codes.InvalidArgument, "missing overrides")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
if err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "instantiate template: %v", err)
}
s.dirty = true
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
names := make([]string, 0, len(entry.Attachments))
for name := range entry.Attachments {
names = append(names, name)
}
slices.Sort(names)
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
}
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if entry.Attachments == nil {
entry.Attachments = map[string][]byte{}
}
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.UploadAttachmentResponse{}, nil
}
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
content, ok := entry.Attachments[req.GetName()]
if !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
return &keepassgov1.DownloadAttachmentResponse{
Content: append([]byte(nil), content...),
}, nil
}
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, ok := entry.Attachments[req.GetName()]; !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
delete(entry.Attachments, req.GetName())
if len(entry.Attachments) == 0 {
entry.Attachments = nil
}
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
s.mu.RLock()
model := s.model
locked := s.locked
s.mu.RUnlock()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
service := clipboard.Service{Writer: s.clipboard}
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, clipboard.ErrUnsupportedTarget):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Errorf(codes.Internal, "copy entry field: %v", err)
}
}
return &keepassgov1.CopyEntryFieldResponse{}, nil
}
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
profile, ok := s.profiles[req.GetProfile()]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
}
password, err := passwords.Generate(profile)
if err != nil {
return nil, status.Errorf(codes.Internal, "generate password: %v", err)
}
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
}
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
return &keepassgov1.Entry{
Id: entry.ID,
Title: entry.Title,
Username: entry.Username,
Password: entry.Password,
Url: entry.URL,
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
}
}
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
return vault.Entry{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
Password: entry.GetPassword(),
URL: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
}
}
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == id {
return entry, nil
}
}
return vault.Entry{}, vault.ErrEntryNotFound
}
func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) {
for i, entry := range model.Entries {
if entry.ID == id {
entry.Attachments = maps.Clone(entry.Attachments)
return entry, i, nil
}
}
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization")
}
if values[0] != "Bearer "+expectedToken {
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
}
return handler(ctx, req)
}
}
+630
View File
@@ -0,0 +1,630 @@
package api
import (
"bytes"
"context"
"net"
"testing"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
_, err := client.ListEntries(context.Background(), &keepassgov1.ListEntriesRequest{})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
}
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false at startup")
}
if statusResp.EntryCount == 0 {
t.Fatal("GetSessionStatus().EntryCount = 0, want non-zero")
}
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
t.Fatalf("LockVault() error = %v", err)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
}
if _, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("ListEntries() code = %v, want FailedPrecondition while locked", status.Code(err))
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}); err != nil {
t.Fatalf("UnlockVault() error = %v", err)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after unlock error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false after unlock")
}
}
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
},
},
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: "correct horse battery staple",
}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if lifecycle.openPath != "/tmp/test.kdbx" {
t.Fatalf("openPath = %q, want /tmp/test.kdbx", lifecycle.openPath)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() after open error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after open", listed.Entries)
}
if _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}); err != nil {
t.Fatalf("SaveVault() error = %v", err)
}
if !lifecycle.saved {
t.Fatal("SaveVault() did not call lifecycle Save")
}
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "jjulian",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
t.Fatalf("OpenRemoteVault() error = %v", err)
}
if lifecycle.remoteBaseURL != "https://dav.example.com" || lifecycle.remotePath != "vaults/main.kdbx" {
t.Fatalf("remote open = %q %q, want dav.example.com vaults/main.kdbx", lifecycle.remoteBaseURL, lifecycle.remotePath)
}
}
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
t.Fatalf("ListGroups().Names = %#v, want [Home Assistant Internet]", listed.Names)
}
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
}); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 3 || listed.Names[0] != "Finance" {
t.Fatalf("ListGroups().Names = %#v, want Finance present after create", listed.Names)
}
if _, err := client.RenameGroup(ctx, &keepassgov1.RenameGroupRequest{
Path: []string{"Root", "Internet"},
NewName: "Infra",
}); err != nil {
t.Fatalf("RenameGroup() error = %v", err)
}
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 3 || listed.Names[2] != "Infra" {
t.Fatalf("ListGroups().Names = %#v, want Infra after rename", listed.Names)
}
}
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if err != nil {
t.Fatalf("GeneratePassword() error = %v", err)
}
if len(resp.Password) < passwords.DefaultProfiles()["strong"].Length {
t.Fatalf("len(GeneratePassword().Password) = %d, want at least %d", len(resp.Password), passwords.DefaultProfiles()["strong"].Length)
}
}
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, clipboardWriter, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
}
if clipboardWriter.content != "token-1" {
t.Fatalf("clipboard content = %q, want %q", clipboardWriter.content, "token-1")
}
}
func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
Url: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
})
if err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 0 {
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
}
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
}
}
func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
}
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
Url: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 2 {
t.Fatalf("len(ListEntries().Entries) = %d, want 2 after template instantiation", len(listed.Entries))
}
}
func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{
Id: "website-login",
Title: "Website Login Updated",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
})
if err != nil {
t.Fatalf("UpsertTemplate() error = %v", err)
}
if upserted.Template.Title != "Website Login Updated" {
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
}
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
}
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
listed, err = client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(listed.Templates) != 0 {
t.Fatalf("ListTemplates().Templates = %#v, want empty after delete", listed.Templates)
}
}
func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
}
if len(history.Entries) != 1 || history.Entries[0].Password != "token-0" {
t.Fatalf("ListEntryHistory().Entries = %#v, want old token entry", history.Entries)
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server",
HistoryIndex: 0,
})
if err != nil {
t.Fatalf("RestoreEntryHistory() error = %v", err)
}
if restored.Entry.Password != "token-0" {
t.Fatalf("RestoreEntryHistory().Entry.Password = %q, want token-0", restored.Entry.Password)
}
}
func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server",
Name: "token.txt",
Content: uploaded,
}); err != nil {
t.Fatalf("UploadAttachment() error = %v", err)
}
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
if len(listed.Names) != 1 || listed.Names[0] != "token.txt" {
t.Fatalf("ListAttachments().Names = %#v, want [token.txt]", listed.Names)
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server",
Name: "token.txt",
})
if err != nil {
t.Fatalf("DownloadAttachment() error = %v", err)
}
if !bytes.Equal(downloaded.Content, uploaded) {
t.Fatalf("DownloadAttachment().Content = %q, want %q", downloaded.Content, uploaded)
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server",
Name: "token.txt",
}); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err)
}
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
if len(listed.Names) != 0 {
t.Fatalf("ListAttachments().Names = %#v, want empty after delete", listed.Names)
}
}
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServer(
vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://git.julianfamily.org",
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"},
},
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
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.",
Tags: []string{"template", "web"},
Path: []string{"Templates"},
},
},
},
passwords.DefaultProfiles(),
clipboardWriter,
))
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
}
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
vault.Model{},
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
))
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
}
type memoryClipboardWriter struct {
content string
}
func (w *memoryClipboardWriter) WriteText(text string) error {
w.content = text
return nil
}
type stubLifecycle struct {
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
}
func (s *stubLifecycle) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
s.openPath = path
return nil
}
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteBaseURL = client.BaseURL
s.remotePath = path
return nil
}
func (s *stubLifecycle) Save() error {
s.saved = true
return nil
}
+620
View File
@@ -0,0 +1,620 @@
package appstate
import (
"fmt"
"slices"
"strings"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
type Section string
const (
SectionEntries Section = ""
SectionTemplates Section = "templates"
SectionRecycleBin Section = "recycle-bin"
)
type CurrentSession interface {
Current() (vault.Model, error)
}
type MutableSession interface {
CurrentSession
Replace(vault.Model)
}
type LockableSession interface {
CurrentSession
Lock() error
Unlock(vault.MasterKey) error
}
type SaveableSession interface {
CurrentSession
Save() error
}
type CreateableSession interface {
CurrentSession
Create(vault.Model, vault.MasterKey) error
}
type OpenableSession interface {
CurrentSession
Open(string, vault.MasterKey) error
}
type SaveAsSession interface {
CurrentSession
SaveAs(string) error
}
type RemoteOpenableSession interface {
CurrentSession
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type State struct {
Session CurrentSession
Section Section
CurrentPath []string
SearchQuery string
SelectedEntryID string
Dirty bool
}
func (s *State) VisibleEntries() ([]vault.Entry, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
entries := s.entriesForSection(model)
if strings.TrimSpace(s.SearchQuery) != "" {
return filterEntries(entries, s.SearchQuery), nil
}
if s.Section == SectionEntries {
return model.EntriesInPath(s.CurrentPath), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
}
return entriesInPath(entries, s.CurrentPath), nil
}
func (s *State) ChildGroups() ([]string, error) {
if strings.TrimSpace(s.SearchQuery) != "" {
return nil, nil
}
model, err := s.currentModel()
if err != nil {
return nil, err
}
if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
}
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
}
return model.ChildGroups(s.CurrentPath), nil
}
func (s *State) SelectVisibleIndex(index int) error {
entries, err := s.VisibleEntries()
if err != nil {
return err
}
if index < 0 || index >= len(entries) {
return fmt.Errorf("visible index %d out of range", index)
}
s.SelectedEntryID = entries[index].ID
return nil
}
func (s *State) ToggleVisibleIndex(index int) error {
entries, err := s.VisibleEntries()
if err != nil {
return err
}
if index < 0 || index >= len(entries) {
return fmt.Errorf("visible index %d out of range", index)
}
if s.SelectedEntryID == entries[index].ID {
s.SelectedEntryID = ""
return nil
}
s.SelectedEntryID = entries[index].ID
return nil
}
func (s *State) currentModel() (vault.Model, error) {
if s.Session == nil {
return vault.Model{}, nil
}
return s.Session.Current()
}
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section {
case SectionTemplates:
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
default:
return slices.Clone(model.Entries)
}
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, entry)
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func filterEntries(entries []vault.Entry, query string) []vault.Entry {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return nil
}
var out []vault.Entry
for _, entry := range entries {
haystack := strings.ToLower(
entry.Title + " " +
entry.Username + " " +
entry.URL + " " +
strings.Join(entry.Path, " "),
)
if !strings.Contains(haystack, query) {
continue
}
out = append(out, entry)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func (s *State) DeleteSelectedEntry() 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 := model.DeleteEntry(s.SelectedEntryID); err != nil {
return err
}
session.Replace(model)
s.SelectedEntryID = ""
s.Dirty = true
return nil
}
func (s *State) RestoreEntry(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.RestoreEntry(id); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) UpsertEntry(entry vault.Entry) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
model.UpsertEntry(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
}
func (s *State) UpsertTemplate(entry vault.Entry) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
model.UpsertTemplate(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
}
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return vault.Entry{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return vault.Entry{}, err
}
entry, err := model.InstantiateTemplate(templateID, overrides)
if err != nil {
return vault.Entry{}, err
}
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return entry, nil
}
func (s *State) DeleteTemplate(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteTemplate(id); err != nil {
return err
}
session.Replace(model)
if s.SelectedEntryID == id {
s.SelectedEntryID = ""
}
s.Dirty = true
return nil
}
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return vault.Entry{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return vault.Entry{}, err
}
duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID)
if err != nil {
return vault.Entry{}, err
}
session.Replace(model)
s.SelectedEntryID = duplicate.ID
s.Dirty = true
return duplicate, nil
}
func (s *State) RestoreSelectedEntryVersion(historyIndex int) 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 := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) Lock() error {
session, ok := s.Session.(LockableSession)
if !ok {
return fmt.Errorf("session is not lockable")
}
if err := session.Lock(); err != nil {
return err
}
s.SelectedEntryID = ""
return nil
}
func (s *State) Unlock(key vault.MasterKey) error {
session, ok := s.Session.(LockableSession)
if !ok {
return fmt.Errorf("session is not lockable")
}
return session.Unlock(key)
}
func (s *State) EnterGroup(name string) {
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
s.SelectedEntryID = ""
}
func (s *State) NavigateToPath(path []string) {
s.CurrentPath = append([]string(nil), path...)
s.SelectedEntryID = ""
}
func (s *State) Save() error {
session, ok := s.Session.(SaveableSession)
if !ok {
return fmt.Errorf("session is not saveable")
}
if err := session.Save(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession)
if !ok {
return fmt.Errorf("session is not createable")
}
if err := session.Create(vault.Model{}, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) OpenVault(path string, key vault.MasterKey) error {
session, ok := s.Session.(OpenableSession)
if !ok {
return fmt.Errorf("session is not openable")
}
if err := session.Open(path, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) SaveAs(path string) error {
session, ok := s.Session.(SaveAsSession)
if !ok {
return fmt.Errorf("session is not save-as capable")
}
if err := session.SaveAs(path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error {
session, ok := s.Session.(RemoteOpenableSession)
if !ok {
return fmt.Errorf("session is not remote-openable")
}
if err := session.OpenRemote(client, path, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) CreateGroup(name 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
}
model.CreateGroup(s.CurrentPath, name)
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
return err
}
session.Replace(model)
if len(s.CurrentPath) > 0 {
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
}
s.Dirty = true
return nil
}
func (s *State) MoveSelectedEntry(path []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if model.Entries[i].Attachments == nil {
model.Entries[i].Attachments = map[string][]byte{}
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
func (s *State) DeleteAttachmentFromSelectedEntry(name 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
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
delete(model.Entries[i].Attachments, name)
if len(model.Entries[i].Attachments) == 0 {
model.Entries[i].Attachments = nil
}
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
+956
View File
@@ -0,0 +1,956 @@
package appstate
import (
"errors"
"slices"
"testing"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
}
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{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "lights",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got))
}
}
func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
},
},
},
Section: SectionRecycleBin,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Joe"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("ChildGroups() = %v, want [Infra Web]", got)
}
}
func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
}
if err := state.ToggleVisibleIndex(1); err != nil {
t.Fatalf("ToggleVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID after toggle = %q, want empty", got)
}
}
func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{err: session.ErrLocked},
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
type stubSession struct {
model vault.Model
err error
}
func (s stubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
}
if err := state.DeleteSelectedEntry(); err != nil {
t.Fatalf("DeleteSelectedEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
if len(sess.model.Entries) != 0 {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after delete")
}
}
func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("git-server"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after restore")
}
}
func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry := vault.Entry{
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 != "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 != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after upsert")
}
}
func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
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.",
Path: []string{"Templates"},
},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if entry.Notes != "Reusable template for website accounts." {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
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 != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template instantiation")
}
}
func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
if err := state.UpsertTemplate(vault.Entry{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Path: []string{"Templates"},
}); err != nil {
t.Fatalf("UpsertTemplate() error = %v", err)
}
if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" {
t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates)
}
if state.SelectedEntryID != "tpl-1" {
t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template upsert")
}
}
func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "tpl-1",
}
if err := state.DeleteTemplate("tpl-1"); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
if len(sess.model.Templates) != 0 {
t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template delete")
}
}
func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "git-server",
}
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
}
if 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))
}
}
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "git-server",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
t.Fatalf("RestoreSelectedEntryVersion() error = %v", err)
}
if got := sess.model.Entries[0].Password; got != "old-token" {
t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after history restore")
}
}
func TestSaveClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &saveableStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save")
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
}
func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
Dirty: true,
}
if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("CreateVault() error = %v", err)
}
if sess.createCalls != 1 {
t.Fatalf("createCalls = %d, want 1", sess.createCalls)
}
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 create")
}
}
func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
Dirty: true,
}
if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if sess.openPath != "/tmp/test.kdbx" {
t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx")
}
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 open")
}
}
func TestSaveAsClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.SaveAs("/tmp/other.kdbx"); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if sess.saveAsPath != "/tmp/other.kdbx" {
t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx")
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save-as")
}
}
func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
client := webdav.Client{BaseURL: "https://example.com"}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
Dirty: true,
}
if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenRemoteVault() error = %v", err)
}
if sess.remotePath != "vaults/main.kdbx" {
t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx")
}
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 remote open")
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
}
if err := state.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty after lock", got)
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
func TestUnlockRestoresVaultVisibility(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
locked: true,
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Unlock() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
}
}
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "git-server",
}
state.EnterGroup("Internet")
if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
}
func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
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)
}
}
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Finance"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
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 TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RenameCurrentGroup("Infra"); err != nil {
t.Fatalf("RenameCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) {
t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RenameCurrentGroup")
}
}
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if string(got[0].Attachments["token.txt"]) != "secret" {
t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry")
}
}
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got[0].Attachments) != 0 {
t.Fatalf("attachments = %#v, want empty", got[0].Attachments)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry")
}
}
type mutableStubSession struct {
model vault.Model
err error
}
func (s *mutableStubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func (s *mutableStubSession) Replace(model vault.Model) {
s.model = model
}
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
},
}
}
type lockableStubSession struct {
model vault.Model
locked bool
}
func (s *lockableStubSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *lockableStubSession) Lock() error {
s.locked = true
return nil
}
func (s *lockableStubSession) Unlock(vault.MasterKey) error {
s.locked = false
return nil
}
type saveableStubSession struct {
saveCalls int
}
func (s *saveableStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *saveableStubSession) Save() error {
s.saveCalls++
return nil
}
type lifecycleStubSession struct {
createCalls int
openPath string
saveAsPath string
remotePath string
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
s.createCalls++
return nil
}
func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error {
s.openPath = path
return nil
}
func (s *lifecycleStubSession) SaveAs(path string) error {
s.saveAsPath = path
return nil
}
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
s.remotePath = path
return nil
}
-27
View File
@@ -1,27 +0,0 @@
# KeePassGO Browser Extension
Shared extension assets for Firefox and Chromium-based browsers live here.
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
- `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
- `options.html` stores the API token in browser extension storage
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
Quick extension-side checks:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
Reproducible Chromium validation:
```bash
make browser-extension-validate
```
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
-947
View File
@@ -1,947 +0,0 @@
const ext = globalThis.browser ?? globalThis.chrome;
const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = {
bearerToken: ""
};
const pageStatePrefix = "keepassgo-page-state:";
const matchCacheTTL = 30 * 1000;
const pendingPollMillis = 1500;
const pageStates = new Map();
const refreshJobs = new Map();
const pendingPollers = new Map();
function storageGet(keys) {
if (usePromiseAPI) {
return ext.storage.local.get(keys);
}
return new Promise((resolve, reject) => {
ext.storage.local.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value);
});
});
}
function storageSet(value) {
if (usePromiseAPI) {
return ext.storage.local.set(value);
}
return new Promise((resolve, reject) => {
ext.storage.local.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionArea() {
return ext.storage?.session ?? null;
}
function sessionStorageGet(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve({});
}
if (usePromiseAPI) {
return area.get(keys).then((value) => value || {});
}
return new Promise((resolve, reject) => {
area.get(keys, (value) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(value || {});
});
});
}
function sessionStorageSet(value) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.set(value);
}
return new Promise((resolve, reject) => {
area.set(value, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function sessionStorageRemove(keys) {
const area = sessionArea();
if (!area) {
return Promise.resolve();
}
if (usePromiseAPI) {
return area.remove(keys);
}
return new Promise((resolve, reject) => {
area.remove(keys, () => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve();
});
});
}
function tabsQuery(query) {
if (usePromiseAPI) {
return ext.tabs.query(query);
}
return new Promise((resolve, reject) => {
ext.tabs.query(query, (tabs) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tabs);
});
});
}
function tabsGet(tabId) {
if (usePromiseAPI) {
return ext.tabs.get(tabId);
}
return new Promise((resolve, reject) => {
ext.tabs.get(tabId, (tab) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(tab);
});
});
}
function tabsSendMessage(tabId, message) {
if (usePromiseAPI) {
return ext.tabs.sendMessage(tabId, message);
}
return new Promise((resolve, reject) => {
ext.tabs.sendMessage(tabId, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function connectNative(message) {
if (usePromiseAPI) {
return ext.runtime.sendNativeMessage(nativeHost, message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const stored = await storageGet(["bearerToken"]);
return {
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
};
}
function supportsPageStateURL(rawURL) {
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
}
function pageStateKey(tabId) {
return `${pageStatePrefix}${String(tabId)}`;
}
function cloneTarget(target) {
return target && typeof target === "object" ? { ...target } : null;
}
function cloneSavePlan(plan) {
if (!plan || typeof plan !== "object") {
return null;
}
return {
mode: plan.mode === "update" ? "update" : "save",
entryId: typeof plan.entryId === "string" ? plan.entryId : "",
title: typeof plan.title === "string" ? plan.title : "",
path: Array.isArray(plan.path) ? [...plan.path] : [],
username: typeof plan.username === "string" ? plan.username : "",
password: typeof plan.password === "string" ? plan.password : "",
url: typeof plan.url === "string" ? plan.url : ""
};
}
function normalizeObservedCredential(observed) {
if (!observed || typeof observed !== "object") {
return null;
}
const password = typeof observed.password === "string" ? observed.password.trim() : "";
const url = typeof observed.url === "string" ? observed.url.trim() : "";
if (!password || !url) {
return null;
}
return {
title: typeof observed.title === "string" ? observed.title.trim() : "",
username: typeof observed.username === "string" ? observed.username.trim() : "",
password,
url
};
}
function matchHost(rawURL) {
if (typeof rawURL !== "string") {
return "";
}
const trimmed = rawURL.trim();
if (!trimmed) {
return "";
}
try {
return new URL(trimmed).hostname.toLowerCase();
} catch (_error) {
return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
}
}
function defaultObservedTitle(observed) {
if (observed?.title) {
return observed.title;
}
return matchHost(observed?.url) || "Browser Login";
}
function savePlanForObservedLogin(observed, matches) {
const normalized = normalizeObservedCredential(observed);
if (!normalized) {
return null;
}
const targetHost = matchHost(normalized.url);
const exact = Array.isArray(matches) ? matches.find((match) =>
typeof match?.id === "string" &&
String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() &&
matchHost(match?.url || "") === targetHost
) : null;
if (exact) {
return {
mode: "update",
entryId: exact.id,
title: exact.title || defaultObservedTitle(normalized),
path: Array.isArray(exact.path) ? [...exact.path] : [],
username: normalized.username,
password: normalized.password,
url: normalized.url
};
}
const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path)
? [...matches[0].path]
: [];
return {
mode: "save",
entryId: "",
title: defaultObservedTitle(normalized),
path: fallbackPath,
username: normalized.username,
password: normalized.password,
url: normalized.url
};
}
function normalizePageState(state) {
return {
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
configured: Boolean(state?.configured),
success: state?.success !== false,
status: state?.status ?? null,
matches: Array.isArray(state?.matches) ? state.matches : [],
error: typeof state?.error === "string" ? state.error : "",
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
signature: typeof state?.signature === "string" ? state.signature : "",
focusTarget: cloneTarget(state?.focusTarget),
pendingFill: Boolean(state?.pendingFill),
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
pendingTarget: cloneTarget(state?.pendingTarget),
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
pendingSave: cloneSavePlan(state?.pendingSave),
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
};
}
function defaultPageState(tabId, pageUrl) {
return normalizePageState({
tabId,
pageUrl,
configured: true,
success: true,
status: null,
matches: [],
error: "",
pageHasLoginForm: false,
signature: "",
focusTarget: null,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
pendingSave: null,
lastFilledEntryId: "",
updatedAt: 0
});
}
async function getPageState(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const existing = pageStates.get(tabId);
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
return normalizePageState(existing);
}
const stored = await sessionStorageGet(pageStateKey(tabId));
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
if (pageUrl && state.pageUrl !== pageUrl) {
return defaultPageState(tabId, pageUrl);
}
pageStates.set(tabId, state);
return state;
}
async function setPageState(tabId, nextState) {
const state = normalizePageState({ ...nextState, tabId });
if (!Number.isInteger(tabId)) {
return state;
}
pageStates.set(tabId, state);
await sessionStorageSet({ [pageStateKey(tabId)]: state });
await updateActionState(tabId, state);
await notifyContentState(tabId, state);
return state;
}
function clearPendingPoll(tabId) {
const timer = pendingPollers.get(tabId);
if (timer !== undefined) {
clearTimeout(timer);
pendingPollers.delete(tabId);
}
}
async function clearPageState(tabId) {
if (!Number.isInteger(tabId)) {
return;
}
pageStates.delete(tabId);
refreshJobs.delete(tabId);
clearPendingPoll(tabId);
await sessionStorageRemove(pageStateKey(tabId));
await clearActionState(tabId);
}
function describeError(error) {
return error instanceof Error ? error.message : String(error || "Unknown error");
}
function approvalHintForState(state) {
if (!state.pendingFill) {
return "";
}
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
}
function shouldContinueWatchingState(state) {
if (!state?.pageHasLoginForm) {
return false;
}
if (state?.pendingFill) {
return true;
}
return Boolean(state?.status?.locked);
}
function schedulePendingPoll(tabId, pageUrl) {
if (!Number.isInteger(tabId)) {
return;
}
clearPendingPoll(tabId);
const timer = setTimeout(() => {
pendingPollers.delete(tabId);
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
}, pendingPollMillis);
pendingPollers.set(tabId, timer);
}
async function notifyContentState(tabId, state) {
if (!Number.isInteger(tabId)) {
return;
}
try {
await tabsSendMessage(tabId, {
type: "keepassgo-page-state",
state
});
} catch (_error) {
// Ignore pages without a ready content script.
}
}
async function clearActionState(tabId) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: "" }),
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
]);
}
function actionPresentationForState(state) {
let badgeText = "";
let title = "KeePassGO Browser";
let color = "#255f4a";
if (state.pendingFill) {
badgeText = "!";
color = "#9f5f0e";
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
} else if (state.pendingSave) {
badgeText = "S";
color = "#255f4a";
title = state.pendingSave.mode === "update"
? `KeePassGO can update ${state.pendingSave.title || "this login"}`
: "KeePassGO can save the submitted login";
} else if (!state.configured) {
title = "Configure KeePassGO Browser in extension settings";
} else if (!state.success) {
badgeText = "!";
color = "#9f2f2f";
title = state.error || "KeePassGO is unavailable for this page";
} else if (state.status?.locked) {
title = "Unlock KeePassGO to fill this page";
} else if (state.pageHasLoginForm && state.matches.length > 0) {
badgeText = String(Math.min(state.matches.length, 9));
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
} else if (state.pageHasLoginForm) {
title = "KeePassGO found no matching entries on this page";
}
return { badgeText, title, color };
}
async function updateActionState(tabId, state) {
if (!Number.isInteger(tabId) || !ext.action) {
return;
}
const presentation = actionPresentationForState(state);
await Promise.allSettled([
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
ext.action.setTitle({ tabId, title: presentation.title })
]);
}
async function activePageContext() {
const [tab] = await tabsQuery({ active: true, currentWindow: true });
return {
tabId: Number.isInteger(tab?.id) ? tab.id : null,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
async function scanTabForLoginForm(tabId) {
if (!Number.isInteger(tabId)) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
try {
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
return {
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
focusTarget: cloneTarget(response?.focusTarget),
signature: typeof response?.signature === "string" ? response.signature : ""
};
} catch (_error) {
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
}
}
function shouldReuseMatches(state, force) {
if (force || state.pendingFill) {
return false;
}
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
return false;
}
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
}
function tokenPendingApprovalCount(status) {
return Number(status?.tokenPendingApprovalCount || 0);
}
async function fetchStatus(settings) {
if (!settings.bearerToken) {
return {
success: false,
configured: false,
status: null,
error: "Set an API token in extension settings."
};
}
const status = await connectNative({
action: "status",
bearerToken: settings.bearerToken
});
return {
success: Boolean(status?.success),
configured: true,
status: status?.status ?? null,
error: status?.error ?? ""
};
}
async function refreshPageState(tabId, pageUrl, options = {}) {
if (!Number.isInteger(tabId)) {
return defaultPageState(null, pageUrl || "");
}
const force = Boolean(options.force);
const existingJob = refreshJobs.get(tabId);
if (existingJob && !force) {
return existingJob;
}
const job = (async () => {
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
if (!supportsPageStateURL(resolvedURL)) {
const tab = await tabsGet(tabId).catch(() => null);
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
}
if (!supportsPageStateURL(resolvedURL)) {
await clearPageState(tabId);
return defaultPageState(tabId, resolvedURL || "");
}
let state = await getPageState(tabId, resolvedURL);
if (state.pageUrl !== resolvedURL) {
state = defaultPageState(tabId, resolvedURL);
}
const scan = typeof options.pageHasLoginForm === "boolean"
? {
pageHasLoginForm: options.pageHasLoginForm,
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
signature: typeof options.signature === "string" ? options.signature : state.signature
}
: await scanTabForLoginForm(tabId);
state = {
...state,
pageUrl: resolvedURL,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
signature: typeof scan.signature === "string" ? scan.signature : state.signature
};
const settings = await loadSettings();
const statusInfo = await fetchStatus(settings).catch((error) => ({
success: false,
configured: true,
status: null,
error: describeError(error)
}));
state = {
...state,
configured: statusInfo.configured,
success: statusInfo.success,
status: statusInfo.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
error: statusInfo.error
};
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
state.matches = [];
state.updatedAt = Date.now();
const saved = await setPageState(tabId, state);
if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
if (shouldReuseMatches(state, force)) {
const saved = await setPageState(tabId, state);
if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
}
const matches = await connectNative({
action: "find-logins",
bearerToken: settings.bearerToken,
url: resolvedURL
});
state = {
...state,
success: Boolean(matches?.success),
status: matches?.status ?? state.status,
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "",
matches: Array.isArray(matches?.matches) ? matches.matches : [],
error: matches?.error ?? "",
updatedAt: Date.now()
};
const saved = await setPageState(tabId, state);
if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL);
} else {
clearPendingPoll(tabId);
}
return saved;
})().finally(() => {
if (refreshJobs.get(tabId) === job) {
refreshJobs.delete(tabId);
}
});
refreshJobs.set(tabId, job);
return job;
}
async function statusForPage(options = {}) {
let page = await activePageContext();
if (Number.isInteger(options.tabId)) {
const tab = await tabsGet(options.tabId).catch(() => null);
page = {
tabId: options.tabId,
url: typeof tab?.url === "string" ? tab.url : ""
};
}
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
if (!options.force) {
const cached = await getPageState(page.tabId, page.url);
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
return cached;
}
}
return refreshPageState(page.tabId, page.url, options);
}
async function fillLogin(tabId, entryId) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
const tab = await tabsGet(tabId);
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled.");
}
let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, {
...state,
pageUrl,
pendingFill: true,
pendingEntryId: String(entryId || "").trim(),
pendingTarget: cloneTarget(state.focusTarget),
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
error: "",
updatedAt: Date.now()
});
schedulePendingPoll(tabId, pageUrl);
try {
const settings = await loadSettings();
if (!settings.bearerToken) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential.");
}
const fillResponse = await tabsSendMessage(tabId, {
type: "keepassgo-fill-credential",
credential: response.credential,
target: state.pendingTarget
});
if (!fillResponse?.ok) {
throw new Error(fillResponse?.error || "The current page could not be filled.");
}
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
lastFilledEntryId: String(entryId || "").trim(),
error: "",
updatedAt: Date.now()
});
clearPendingPoll(tabId);
return {
credential: response.credential,
pageUrl,
state
};
} catch (error) {
state = await setPageState(tabId, {
...state,
pendingFill: false,
pendingEntryId: "",
pendingTarget: null,
pendingMessage: "",
error: describeError(error),
updatedAt: Date.now()
});
clearPendingPoll(tabId);
throw error;
}
}
async function refreshActivePage(options = {}) {
const page = await activePageContext();
if (page.tabId == null) {
return defaultPageState(null, page.url);
}
return refreshPageState(page.tabId, page.url, options);
}
async function saveObservedLogin(tabId, selectedMatch = null) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
const tab = await tabsGet(tabId);
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
let state = await getPageState(tabId, pageUrl);
const pendingSave = cloneSavePlan(state.pendingSave);
if (!pendingSave) {
throw new Error("There is no pending login to save.");
}
const request = {
action: "save-login",
title: pendingSave.title,
username: pendingSave.username,
password: pendingSave.password,
url: pendingSave.url
};
if (selectedMatch && typeof selectedMatch === "object") {
if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) {
request.entryId = selectedMatch.id.trim();
request.title = String(selectedMatch.title || pendingSave.title || "").trim();
} else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) {
request.path = [...selectedMatch.path];
}
} else if (pendingSave.mode === "update" && pendingSave.entryId) {
request.entryId = pendingSave.entryId;
} else if (pendingSave.path.length > 0) {
request.path = [...pendingSave.path];
}
const settings = await loadSettings();
if (!settings.bearerToken) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
...request,
bearerToken: settings.bearerToken
});
if (!response?.success) {
throw new Error(response?.error || "KeePassGO did not save the submitted login.");
}
state = await setPageState(tabId, {
...state,
pendingSave: null,
error: "",
updatedAt: Date.now()
});
await refreshPageState(tabId, pageUrl, { force: true });
return { state };
}
const backgroundTestExports = {
normalizePageState,
actionPresentationForState,
shouldReuseMatches,
shouldContinueWatchingState,
tokenPendingApprovalCount,
savePlanForObservedLogin,
defaultSettings
};
if (isNodeTestEnv) {
module.exports = backgroundTestExports;
} else {
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
switch (message?.type) {
case "keepassgo-popup-state":
sendResponse(await statusForPage({
force: Boolean(message.force),
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
}));
return;
case "keepassgo-fill-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
if (Number.isInteger(targetTabID) && message.target) {
const targetState = await getPageState(targetTabID, "");
await setPageState(targetTabID, {
...targetState,
focusTarget: cloneTarget(message.target)
});
}
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-load-settings":
sendResponse({ success: true, settings: await loadSettings() });
return;
case "keepassgo-save-settings":
await storageSet({
bearerToken: String(message.settings?.bearerToken || "").trim()
});
await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true });
return;
case "keepassgo-search-logins": {
const settings = await loadSettings();
const response = await connectNative({
action: "search-logins",
bearerToken: settings.bearerToken,
query: String(message?.query || "").trim()
});
sendResponse({
success: Boolean(response?.success),
error: response?.error || "",
results: Array.isArray(response?.searchResults) ? response.searchResults : [],
status: response?.status ?? null
});
return;
}
case "keepassgo-observed-login":
if (Number.isInteger(sender?.tab?.id)) {
const targetState = await getPageState(sender.tab.id, sender.tab.url || "");
const nextSave = savePlanForObservedLogin(message.observed, targetState.matches);
sendResponse(await setPageState(sender.tab.id, {
...targetState,
pendingSave: nextSave,
updatedAt: Date.now()
}));
return;
}
sendResponse({ success: false, error: "No active tab is available." });
return;
case "keepassgo-save-login": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object"
? {
id: String(message.selectedMatch.id || "").trim(),
title: String(message.selectedMatch.title || "").trim(),
path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : []
}
: null;
sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) });
return;
}
case "keepassgo-page-ready":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
force: Boolean(message.force),
pageHasLoginForm: Boolean(message.pageHasLoginForm),
focusTarget: cloneTarget(message.focusTarget),
signature: typeof message.signature === "string" ? message.signature : ""
}));
return;
}
sendResponse(defaultPageState(null, ""));
return;
case "keepassgo-refresh-page-state":
if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
return;
}
sendResponse(defaultPageState(null, ""));
return;
default:
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
}
})().catch((error) => {
sendResponse({ success: false, error: describeError(error) });
});
return true;
});
ext.tabs?.onActivated?.addListener(({ tabId }) => {
void refreshPageState(tabId, "", { force: false }).catch(() => null);
});
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
if (typeof changeInfo.url === "string") {
void clearPageState(tabId).catch(() => null);
}
if (changeInfo.status === "complete") {
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
}
});
ext.tabs?.onRemoved?.addListener((tabId) => {
void clearPageState(tabId).catch(() => null);
});
}
-131
View File
@@ -1,131 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const background = require("./background.js");
test("normalizePageState preserves focused and pending field targets", () => {
const state = background.normalizePageState({
tabId: 7,
pageUrl: "https://vault.example.invalid/login",
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
});
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
});
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
const recentState = {
pageHasLoginForm: true,
matches: [{ id: "vault-console" }],
pendingFill: false,
updatedAt: Date.now()
};
assert.equal(background.shouldReuseMatches(recentState, false), true);
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
assert.equal(background.shouldReuseMatches(recentState, true), false);
});
test("actionPresentationForState prioritizes approval visibility", () => {
const presentation = background.actionPresentationForState({
pendingFill: true,
pendingMessage: "Approve the browser fill request in KeePassGO.",
configured: true,
success: true,
pageHasLoginForm: true,
matches: [{ id: "vault-console" }]
});
assert.equal(presentation.badgeText, "!");
assert.equal(presentation.color, "#9f5f0e");
assert.match(presentation.title, /approve/i);
});
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
assert.equal(background.tokenPendingApprovalCount({}), 0);
});
test("shouldContinueWatchingState keeps polling locked login pages", () => {
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: true }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: true,
status: { locked: false }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: false }
}), false);
});
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, "");
});
test("savePlanForObservedLogin prefers updating an exact username match", () => {
const plan = background.savePlanForObservedLogin({
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
},
{
id: "bellagio-backup",
title: "Bellagio Backup",
username: "rustyryan",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "update",
entryId: "vault-console",
title: "Vault Console",
path: ["Crew", "Internet"],
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
});
});
test("savePlanForObservedLogin falls back to saving into the current page group", () => {
const plan = background.savePlanForObservedLogin({
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "save",
entryId: "",
title: "vault.example.invalid",
path: ["Crew", "Internet"],
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
});
});
-899
View File
@@ -1,899 +0,0 @@
const ext = globalThis.browser ?? globalThis.chrome;
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return ext.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
ext.runtime.sendMessage(message, (response) => {
const error = ext.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function isVisibleInput(input) {
if (!(input instanceof HTMLInputElement)) {
return false;
}
if (input.disabled || input.readOnly) {
return false;
}
const style = window.getComputedStyle(input);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
return input.offsetParent !== null || style.position === "fixed";
}
function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) {
case "password":
return "password";
case "username":
return "username";
default:
return "";
}
}
function lowerJoined(values) {
return values
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean)
.join(" ");
}
function fieldHintText(input) {
if (!input || typeof input !== "object") {
return "";
}
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
return lowerJoined([
input.getAttribute?.("type"),
input.getAttribute?.("name"),
input.getAttribute?.("id"),
input.autocomplete,
input.getAttribute?.("autocomplete"),
input.getAttribute?.("placeholder"),
input.getAttribute?.("aria-label"),
...labels
]);
}
function textLikeInputType(type) {
switch (String(type || "").toLowerCase()) {
case "":
case "text":
case "email":
case "tel":
case "number":
return true;
default:
return false;
}
}
function hintMatches(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function scopeHintText(scope) {
const isDocumentScope = typeof document !== "undefined" && scope === document;
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
return "";
}
const attrText = isDocumentScope ? "" : lowerJoined([
scope.getAttribute?.("id"),
scope.getAttribute?.("name"),
scope.getAttribute?.("class"),
scope.getAttribute?.("action"),
scope.getAttribute?.("aria-label")
]);
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
.slice(0, 8)
.map((element) => element.textContent || ""));
return lowerJoined([attrText, headingText]);
}
function hasAuthFlowSignals(usernameInput, scope) {
if (usernameInput) {
return true;
}
return hintMatches(scopeHintText(scope), authScopePatterns);
}
const usernameHintPatterns = [
/\buser(name|id)?\b/,
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bemail\b/,
/\be-mail\b/,
/\baccount\b/,
/\bmember\b/,
/\bidentifier\b/
];
const nonLoginHintPatterns = [
/\bsearch\b/,
/\bquery\b/,
/\bfilter\b/,
/\bcomment\b/,
/\bmessage\b/,
/\bcontact\b/,
/\bcity\b/,
/\bstate\b/,
/\bpostal\b/,
/\bzip\b/,
/\bcoupon\b/,
/\bpromo\b/,
/\bnewsletter\b/,
/\bsubscribe\b/
];
const authScopePatterns = [
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bauth\b/,
/\bpassword\b/,
/\bpasscode\b/,
/\b2fa\b/,
/\btwo[\s-]?factor\b/,
/\bverify\b/,
/\baccount\b/
];
function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") {
return "password";
}
if (!textLikeInputType(type)) {
return "";
}
const hints = fieldHintText(input);
if (!hints) {
return "";
}
if (hintMatches(hints, nonLoginHintPatterns)) {
return "";
}
if (hintMatches(hints, usernameHintPatterns)) {
return "username";
}
return "";
}
function isUsernameCandidate(input) {
if (!isVisibleInput(input)) {
return false;
}
return describeFieldRole(input) === "username";
}
function isPasswordCandidate(input) {
return isVisibleInput(input) && describeFieldRole(input) === "password";
}
function dispatchFillEvents(input) {
if (typeof InputEvent === "function") {
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
} else {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function setInputValue(input, value) {
const prototype = Object.getPrototypeOf(input);
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
if (descriptor?.set) {
descriptor.set.call(input, value);
return;
}
input.value = value;
}
function visibleInputs(scope) {
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
}
function resolveFormInputs(anchorInput) {
if (anchorInput?.form instanceof HTMLFormElement) {
return visibleInputs(anchorInput.form);
}
return visibleInputs(document);
}
function firstVisiblePassword(scope) {
return visibleInputs(scope).find(isPasswordCandidate) || null;
}
function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null;
}
function authFlowCandidate(anchorInput) {
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
if (!passwordInput) {
return null;
}
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
return null;
}
return {
usernameInput: associated.usernameInput,
passwordInput,
anchorInput: anchorInput || passwordInput,
scope
};
}
function loginCandidates() {
const candidates = [];
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
const candidate = authFlowCandidate(passwordInput);
if (!candidate) {
continue;
}
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
continue;
}
candidates.push(candidate);
}
return candidates;
}
function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
let usernameInput = usernameInScope[0] || null;
if (passwordInput && usernameInScope.length !== 0) {
const priorSibling = usernameInScope.find((input) =>
typeof input.compareDocumentPosition === "function" &&
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
);
usernameInput = priorSibling || usernameInScope[0] || null;
}
if (!usernameInput) {
usernameInput = firstVisibleUsername(document);
}
return { usernameInput, passwordInput };
}
function buildFieldDescriptor(input, role) {
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
return null;
}
const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document;
const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input);
const forms = Array.from(document.forms || []);
return {
role: normalizedRole,
formIndex: form ? forms.indexOf(form) : -1,
fieldIndex,
id: String(input.id || ""),
name: String(input.name || ""),
autocomplete: String(input.autocomplete || "").toLowerCase()
};
}
function resolveFieldDescriptor(descriptor) {
if (!descriptor || typeof descriptor !== "object") {
return null;
}
const normalizedRole = normalizeRole(descriptor.role);
if (!normalizedRole) {
return null;
}
const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document;
const inputs = visibleInputs(scope);
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
const candidate = inputs[descriptor.fieldIndex];
if (describeFieldRole(candidate) === normalizedRole) {
return candidate;
}
}
if (descriptor.id) {
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
return byID;
}
}
if (descriptor.name) {
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
if (byName) {
return byName;
}
}
if (normalizedRole === "password") {
return firstVisiblePassword(scope) || firstVisiblePassword(document);
}
return firstVisibleUsername(scope) || firstVisibleUsername(document);
}
function chooseFillTargets(targetDescriptor) {
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
const associated = associatedFieldsForAnchor(anchorInput);
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: associated.usernameInput,
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
anchorInput
};
}
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
return {
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput
};
}
return {
usernameInput: associated.usernameInput,
passwordInput: associated.passwordInput,
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
};
}
function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const explicitRole = describeFieldRole(activeUsable);
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
const candidates = loginCandidates();
const chosen = activeTargets || candidates[0] || null;
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
const focusRole = explicitRole || describeFieldRole(anchorInput);
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
const roles = candidates.map((candidate) => {
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
});
return {
pageHasLoginForm: Boolean(chosen),
usernameInput: chosen?.usernameInput || null,
passwordInput: chosen?.passwordInput || null,
anchorInput,
focusTarget,
signature: roles.join("|")
};
}
function fillCredential(credential, targetDescriptor) {
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
if (usernameInput && credential.username) {
usernameInput.focus();
setInputValue(usernameInput, credential.username);
dispatchFillEvents(usernameInput);
}
if (passwordInput && credential.password) {
passwordInput.focus();
setInputValue(passwordInput, credential.password);
dispatchFillEvents(passwordInput);
}
if (!usernameInput && !passwordInput) {
return { ok: false, error: "No fillable username or password fields were found." };
}
return { ok: true };
}
function submittedCredential(candidate, rawURL) {
if (!candidate?.passwordInput) {
return null;
}
const password = String(candidate.passwordInput.value || "").trim();
if (!password) {
return null;
}
return {
title: domainLabel(rawURL),
username: String(candidate.usernameInput?.value || "").trim(),
password,
url: String(rawURL || "").trim()
};
}
function domainLabel(rawURL) {
try {
return new URL(rawURL).host || "";
} catch (_error) {
return "";
}
}
function inlineMatchSummary(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (match.url) {
const host = domainLabel(match.url);
if (host) {
parts.push(host);
}
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
if (suppressed || idleHidden || !hasTarget) {
return false;
}
return Boolean(
state?.pageHasLoginForm &&
(
state?.pendingFill ||
(state?.configured && state?.success && state?.status?.locked) ||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
)
);
}
const contentTestExports = {
normalizeRole,
describeFieldRole,
buildFieldDescriptor,
resolveFieldDescriptor,
chooseFillTargets,
inlineMatchSummary,
domainLabel,
shouldShowInlineOverlay,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate,
submittedCredential
};
if (isNodeTestEnv) {
module.exports = contentTestExports;
} else {
let pageState = {
configured: true,
success: true,
matches: [],
pageHasLoginForm: false,
pendingFill: false,
error: "",
focusTarget: null
};
let chooserOpen = false;
let inlineSuppressed = false;
let inlineIdleHidden = false;
let refreshTimer = null;
let idleHideTimer = null;
let lastReportedSignature = "";
let lastReportedTarget = "";
const root = document.createElement("div");
root.id = "keepassgo-inline-root";
root.setAttribute("aria-live", "polite");
const shadow = root.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
all: initial;
}
.dock {
position: fixed;
z-index: 2147483647;
display: none;
min-width: 220px;
max-width: min(340px, calc(100vw - 24px));
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
color: #214f44;
}
.dock[data-open="true"] .panel {
display: block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #c6d8cf;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #edf5f0);
color: #214f44;
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
cursor: pointer;
}
.trigger[data-tone="warning"] {
border-color: #e4d0ae;
background: linear-gradient(180deg, #fff8ed, #f9edd6);
color: #7f4b09;
}
.trigger[data-tone="error"] {
border-color: #e4bcbc;
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
color: #8c2f2f;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.meta {
color: #4d6d66;
font-size: 12px;
}
.panel {
display: none;
margin-top: 8px;
border: 1px solid #d7e3dc;
border-radius: 16px;
background: #fffdfa;
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
overflow: hidden;
}
.panel-header {
padding: 12px 14px 10px;
border-bottom: 1px solid #e6efea;
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
}
.panel-title {
font-weight: 700;
}
.panel-copy {
margin-top: 4px;
color: #4d6d66;
font-size: 12px;
}
.match-list {
display: grid;
gap: 0;
max-height: 280px;
overflow: auto;
}
.match {
display: grid;
gap: 3px;
width: 100%;
padding: 12px 14px;
border: 0;
border-top: 1px solid #eef4f0;
background: #fffdfa;
color: #214f44;
text-align: left;
cursor: pointer;
}
.match:hover,
.match:focus-visible {
background: #edf5f0;
outline: none;
}
.match strong {
font-size: 13px;
}
.pill {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 999px;
background: #e7f1eb;
color: #255f4a;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtle {
color: #4d6d66;
font-size: 12px;
}
.empty {
padding: 12px 14px;
color: #4d6d66;
font-size: 12px;
}
</style>
<div class="dock" data-open="false">
<button type="button" class="trigger" data-tone="ready">
<span class="brand">KeePassGO</span>
<span class="meta">Checking this form</span>
</button>
<div class="panel">
<div class="panel-header">
<div class="panel-title">KeePassGO suggestions</div>
<div class="panel-copy">Select a matching login for this field.</div>
</div>
<div class="match-list"></div>
</div>
</div>
`;
const dock = shadow.querySelector(".dock");
const trigger = shadow.querySelector(".trigger");
const meta = shadow.querySelector(".meta");
const matchList = shadow.querySelector(".match-list");
const panelCopy = shadow.querySelector(".panel-copy");
function ensureRootMounted() {
if (!root.isConnected) {
document.documentElement.appendChild(root);
}
}
function currentTarget() {
return chooseFillTargets(pageState.focusTarget).anchorInput;
}
function hideDock() {
chooserOpen = false;
dock.style.display = "none";
dock.dataset.open = "false";
}
function clearIdleHideTimer() {
if (idleHideTimer !== null) {
clearTimeout(idleHideTimer);
idleHideTimer = null;
}
}
function refreshInlineLifetime(shouldShow) {
clearIdleHideTimer();
if (!shouldShow || chooserOpen || pageState.pendingFill) {
return;
}
idleHideTimer = window.setTimeout(() => {
inlineIdleHidden = true;
hideDock();
}, 15000);
}
function positionDock() {
const anchor = currentTarget();
if (!anchor || dock.style.display === "none") {
return;
}
const rect = anchor.getBoundingClientRect();
const width = Math.min(340, Math.max(220, rect.width));
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
dock.style.left = `${left}px`;
dock.style.top = `${top}px`;
dock.style.width = `${width}px`;
}
function renderMatches() {
matchList.textContent = "";
if (pageState.pendingFill) {
const pending = document.createElement("div");
pending.className = "empty";
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
matchList.appendChild(pending);
return;
}
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = pageState.error || "No matching entries were found for this page.";
matchList.appendChild(empty);
return;
}
for (const match of pageState.matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match";
const title = document.createElement("strong");
title.textContent = match.title;
const summary = document.createElement("span");
summary.className = "subtle";
summary.textContent = inlineMatchSummary(match);
const quality = document.createElement("span");
quality.className = "pill";
quality.textContent = match.quality || "Candidate";
row.appendChild(title);
row.appendChild(summary);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
inlineSuppressed = true;
hideDock();
try {
await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
target: pageState.focusTarget
});
} catch (_error) {
pageState = {
...pageState,
pendingFill: false,
error: "KeePassGO could not fill this page."
};
renderInlineState();
} finally {
row.disabled = false;
}
});
matchList.appendChild(row);
}
}
function renderInlineState() {
const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
if (!shouldShow) {
clearIdleHideTimer();
hideDock();
return;
}
ensureRootMounted();
dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill || pageState.status?.locked ? "warning" : (pageState.error ? "error" : "ready");
if (pageState.pendingFill) {
meta.textContent = "Approval needed in KeePassGO";
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} else if (pageState.status?.locked) {
meta.textContent = "Unlock KeePassGO";
panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions.";
} else {
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
panelCopy.textContent = "Select a matching login for this field.";
}
dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches();
positionDock();
refreshInlineLifetime(shouldShow);
}
function reportFieldState(force) {
const scan = scanLoginFields();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
inlineIdleHidden = false;
}
pageState = {
...pageState,
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget
};
renderInlineState();
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return;
}
lastReportedSignature = scan.signature;
lastReportedTarget = nextTarget;
void runtimeSend({
type: "keepassgo-page-ready",
force: Boolean(force),
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
}).then((response) => {
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
pageState = {
...pageState,
...response,
pageHasLoginForm: Boolean(response.pageHasLoginForm),
focusTarget: response.focusTarget || pageState.focusTarget
};
renderInlineState();
}
}).catch(() => null);
}
function scheduleRefresh(force) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
refreshTimer = null;
reportFieldState(force);
}, force ? 0 : 120);
}
trigger.addEventListener("click", () => {
chooserOpen = !chooserOpen;
renderInlineState();
});
document.addEventListener("focusin", () => {
scheduleRefresh(false);
});
document.addEventListener("input", () => {
scheduleRefresh(false);
}, true);
document.addEventListener("submit", (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!form) {
return;
}
const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
const observed = submittedCredential(candidate, window.location.href);
if (!observed) {
return;
}
void runtimeSend({
type: "keepassgo-observed-login",
observed
}).catch(() => null);
}, true);
document.addEventListener("click", (event) => {
if (!root.contains(event.target)) {
chooserOpen = false;
renderInlineState();
}
});
window.addEventListener("scroll", () => {
positionDock();
}, true);
window.addEventListener("resize", () => {
positionDock();
});
const observer = new MutationObserver((records) => {
if (records.some((record) => record.type === "childList")) {
scheduleRefresh(false);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "keepassgo-fill-credential") {
try {
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
} catch (error) {
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
return false;
}
if (message?.type === "keepassgo-page-state") {
pageState = {
...pageState,
...(message.state || {})
};
renderInlineState();
sendResponse({ ok: true });
return false;
}
if (message?.type === "keepassgo-page-scan") {
const scan = scanLoginFields();
sendResponse({
pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget,
signature: scan.signature
});
return false;
}
return false;
});
reportFieldState(true);
}
-136
View File
@@ -1,136 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const content = require("./content.js");
test("inlineMatchSummary includes username, host, and path context", () => {
const summary = content.inlineMatchSummary({
username: "dannyocean",
url: "https://vault.example.invalid/login",
path: ["Root", "Crew"]
});
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
});
test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
assert.equal(content.domainLabel("not-a-url"), "");
});
test("describeFieldRole only treats explicit account fields as usernames", () => {
const loginField = {
autocomplete: "username",
labels: [],
getAttribute(name) {
const attrs = {
type: "email",
id: "crew-email",
name: "email",
placeholder: "Email address",
"aria-label": "Email address"
};
return attrs[name] || "";
}
};
const searchField = {
autocomplete: "",
labels: [],
getAttribute(name) {
const attrs = {
type: "text",
id: "site-search",
name: "query",
placeholder: "Search casino news",
"aria-label": "Search"
};
return attrs[name] || "";
}
};
assert.equal(content.describeFieldRole(loginField), "username");
assert.equal(content.describeFieldRole(searchField), "");
});
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
const genericScope = {
getAttribute() {
return "";
},
querySelectorAll() {
return [{ textContent: "Confirm shipment" }];
}
};
const signInScope = {
getAttribute(name) {
const attrs = {
id: "signin-panel",
name: "signin",
action: "/session"
};
return attrs[name] || "";
},
querySelectorAll() {
return [{ textContent: "Sign in to the Bellagio vault" }];
}
};
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "vault-console" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
});
test("shouldShowInlineOverlay stays visible for locked login pages", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: true },
matches: [],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
});
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "rusty-ryan" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
});
test("submittedCredential captures the pending browser save payload from a login candidate", () => {
const candidate = {
usernameInput: { value: "linuscaldwell" },
passwordInput: { value: "yellow-chip" }
};
assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
title: "bellagio.example.invalid",
username: "linuscaldwell",
password: "yellow-chip",
url: "https://bellagio.example.invalid/login"
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

-26
View File
@@ -1,26 +0,0 @@
{
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "KeePassGO Browser",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
-55
View File
@@ -1,55 +0,0 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"96": "icons/icon-96.png",
"128": "icons/icon-128.png"
},
"permissions": [
"activeTab",
"nativeMessaging",
"storage",
"tabs",
"http://*/*",
"https://*/*"
],
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_title": "KeePassGO Browser",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
},
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"browser_specific_settings": {
"gecko": {
"id": "browser@keepassgo.com",
"data_collection_permissions": {
"required": ["authenticationInfo", "websiteActivity"]
},
"strict_min_version": "140.0"
},
"gecko_android": {
"strict_min_version": "142.0"
}
}
}
-30
View File
@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser Settings</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="surface settings">
<header class="topbar">
<div>
<h1>Browser Settings</h1>
<p class="subtle">Connect the extension to KeePassGO.</p>
</div>
</header>
<form id="settings-form" class="settings-form">
<label>
<span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
</div>
<p id="settings-status" class="subtle"></p>
</form>
</main>
<script src="options.js"></script>
</body>
</html>
-49
View File
@@ -1,49 +0,0 @@
const extOptions = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extOptions.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extOptions.runtime.sendMessage(message, (response) => {
const error = extOptions.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
async function loadSettings() {
const response = await runtimeSend({ type: "keepassgo-load-settings" });
if (!response?.success) {
throw new Error(response?.error || "Could not load settings.");
}
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
}
async function saveSettings(event) {
event.preventDefault();
const status = document.getElementById("settings-status");
status.textContent = "Saving…";
try {
const response = await runtimeSend({
type: "keepassgo-save-settings",
settings: {
bearerToken: document.getElementById("bearer-token").value
}
});
if (!response?.success) {
throw new Error(response?.error || "Could not save settings.");
}
status.textContent = "Saved.";
} catch (error) {
status.textContent = error instanceof Error ? error.message : String(error);
}
}
document.getElementById("settings-form").addEventListener("submit", saveSettings);
void loadSettings();
-45
View File
@@ -1,45 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePassGO Browser</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="popup">
<main class="surface">
<header class="topbar">
<div>
<h1>KeePassGO</h1>
<p id="page-host" class="subtle">Checking current page</p>
</div>
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
</header>
<section id="status-card" class="status-card">
<strong id="status-title">Loading</strong>
<p id="status-message" class="subtle">Checking KeePassGO.</p>
</section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section id="save-card" class="save-card" hidden>
<div>
<h2>Save Submitted Login</h2>
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
</div>
<button id="save-action" type="button">Save Login</button>
</section>
<section>
<h2>Matches</h2>
<div id="matches" class="match-list"></div>
</section>
<section class="search-section">
<h2>Search Vault</h2>
<form id="search-form" class="search-form">
<input id="search-query" type="search" placeholder="Search entries" autocomplete="off">
<button type="submit">Search</button>
</form>
<div id="search-results" class="match-list"></div>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>
-299
View File
@@ -1,299 +0,0 @@
const extPopup = globalThis.browser ?? globalThis.chrome;
const usePromiseAPI = typeof globalThis.browser !== "undefined";
function runtimeSend(message) {
if (usePromiseAPI) {
return extPopup.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
extPopup.runtime.sendMessage(message, (response) => {
const error = extPopup.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response);
});
});
}
function hostFromURL(rawURL) {
try {
return new URL(rawURL).host || rawURL;
} catch (_error) {
return rawURL || "Current page";
}
}
function setStatus(title, message, tone) {
const card = document.getElementById("status-card");
card.dataset.tone = tone || "neutral";
document.getElementById("status-title").textContent = title;
document.getElementById("status-message").textContent = message;
}
function matchSubtitle(match) {
const parts = [];
if (match.username) {
parts.push(match.username);
}
if (Array.isArray(match.path) && match.path.length !== 0) {
parts.push(match.path.join(" / "));
}
return parts.join(" · ") || "No username";
}
function saveCardLabel(pendingSave) {
return pendingSave?.mode === "update"
? `Update ${pendingSave.title || "Login"}`
: "Save Login";
}
function renderMatchList(root, matches, options = {}) {
const targetTabID = popupTabID();
const emptyMessage = options.emptyMessage || "No matching entries.";
root.textContent = "";
if (!Array.isArray(matches) || matches.length === 0) {
const empty = document.createElement("p");
empty.className = "subtle";
empty.textContent = emptyMessage;
root.appendChild(empty);
return;
}
for (const match of matches) {
const row = document.createElement("button");
row.type = "button";
row.className = "match-row";
const main = document.createElement("span");
main.className = "match-main";
const title = document.createElement("strong");
title.textContent = match.title;
const subtitle = document.createElement("span");
subtitle.className = "subtle";
subtitle.textContent = matchSubtitle(match);
const quality = document.createElement("span");
quality.className = "quality";
quality.textContent = match.quality || "";
main.appendChild(title);
main.appendChild(subtitle);
row.appendChild(main);
row.appendChild(quality);
row.addEventListener("click", async () => {
row.disabled = true;
try {
if (typeof options.onSelect === "function") {
await options.onSelect(match, targetTabID);
} else {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({
type: "keepassgo-fill-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
} catch (error) {
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
});
root.appendChild(row);
}
}
function renderMatches(state) {
const emptyMessage = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
const root = document.getElementById("matches");
if (state.pendingSave) {
renderMatchList(root, state.matches, {
emptyMessage,
onSelect: async (match, targetTabID) => {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: targetTabID,
selectedMatch: {
id: match.id,
title: match.title,
path: Array.isArray(match.path) ? match.path : []
}
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
document.getElementById("save-card").hidden = true;
}
});
return;
}
renderMatchList(root, state.matches, { emptyMessage });
}
function renderSearchResults(results, query) {
const root = document.getElementById("search-results");
if (!query) {
root.textContent = "";
const hint = document.createElement("p");
hint.className = "subtle";
hint.textContent = "Search all entries you can access with this token.";
root.appendChild(hint);
return;
}
renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".`
});
}
function renderPageHint(state) {
const hint = document.getElementById("page-hint");
if (state.pendingFill) {
hint.textContent = "Approval is pending in KeePassGO.";
return;
}
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
return;
}
if (state.pageHasLoginForm) {
hint.textContent = "KeePassGO checked this login form already.";
return;
}
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
}
function renderPendingSave(state) {
const card = document.getElementById("save-card");
const message = document.getElementById("save-message");
const action = document.getElementById("save-action");
const pendingSave = state.pendingSave;
if (!pendingSave) {
card.hidden = true;
action.onclick = null;
return;
}
card.hidden = false;
action.textContent = saveCardLabel(pendingSave);
if (pendingSave.mode === "update") {
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
} else {
message.textContent = "Search the vault below to choose a group for this submitted login.";
}
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
action.onclick = async () => {
action.disabled = true;
try {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: popupTabID()
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
card.hidden = true;
} catch (error) {
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
} finally {
action.disabled = false;
}
};
}
function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) {
return null;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isInteger(parsed) ? parsed : null;
}
async function searchVault(event) {
event.preventDefault();
const query = document.getElementById("search-query").value.trim();
const resultsRoot = document.getElementById("search-results");
if (!query) {
renderSearchResults([], "");
return;
}
resultsRoot.textContent = "";
const loading = document.createElement("p");
loading.className = "subtle";
loading.textContent = "Searching KeePassGO…";
resultsRoot.appendChild(loading);
try {
const response = await runtimeSend({
type: "keepassgo-search-logins",
query
});
if (!response?.success) {
throw new Error(response?.error || "Search failed.");
}
renderSearchResults(Array.isArray(response.results) ? response.results : [], query);
} catch (error) {
renderSearchResults([], query);
setStatus("Search failed", error instanceof Error ? error.message : String(error), "error");
}
}
async function main() {
try {
document.getElementById("search-form").addEventListener("submit", searchVault);
renderSearchResults([], "");
const state = await runtimeSend({
type: "keepassgo-popup-state",
force: true,
tabId: popupTabID()
});
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state);
renderPendingSave(state);
if (!state.configured) {
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
renderMatches({ matches: [] });
return;
}
if (state.pendingFill) {
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
renderMatches(state);
return;
}
if (!state.success) {
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
renderMatches(state);
return;
}
if (state.status?.locked) {
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
renderMatches(state);
return;
}
const count = Array.isArray(state.matches) ? state.matches.length : 0;
if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
} else if (state.pendingSave) {
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
} else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else {
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
}
renderMatches(state);
} catch (error) {
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
renderMatches({ matches: [] });
}
}
void main();
-201
View File
@@ -1,201 +0,0 @@
:root {
color-scheme: light;
--ink: #214f44;
--ink-soft: #4d6d66;
--surface: #fffdfa;
--surface-2: #f2f7f3;
--line: #d7e3dc;
--accent: #255f4a;
--accent-soft: #dfeee6;
--warn: #9f5f0e;
--error: #9f2f2f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
linear-gradient(180deg, #f8fbf8, #eef4f0);
}
body.popup {
min-width: 360px;
}
.surface {
padding: 16px;
}
.topbar {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
line-height: 1.1;
}
h2 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
color: var(--ink-soft);
}
.subtle {
color: var(--ink-soft);
}
.status-card {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--surface);
margin-bottom: 16px;
}
.status-card[data-tone="ready"] {
border-color: #c5dccf;
background: var(--accent-soft);
}
.status-card[data-tone="warning"] {
border-color: #e4d0ae;
background: #fbf4e7;
}
.status-card[data-tone="error"] {
border-color: #e4bcbc;
background: #fcf1f1;
}
.inline-hint {
margin: -6px 0 16px;
}
.match-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.search-section {
margin-top: 16px;
}
.save-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin: 0 0 16px;
border: 1px solid #c5dccf;
border-radius: 12px;
background: var(--accent-soft);
}
.search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 10px;
}
.match-row,
button,
.link-button {
appearance: none;
border: 0;
border-radius: 12px;
background: var(--surface);
color: var(--ink);
text-decoration: none;
}
.match-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
cursor: pointer;
}
.match-row:hover,
button:hover,
.link-button:hover {
background: var(--surface-2);
}
.match-main {
display: flex;
flex-direction: column;
align-items: start;
text-align: left;
}
.quality {
font-size: 12px;
color: var(--ink-soft);
text-transform: uppercase;
}
.settings {
max-width: 720px;
margin: 0 auto;
min-height: 100vh;
}
.settings-form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
}
input,
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--ink);
font: inherit;
}
button,
.link-button {
padding: 10px 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.actions {
display: flex;
justify-content: end;
}
-106
View File
@@ -1,106 +0,0 @@
package buildapk
import (
"fmt"
"os"
"path/filepath"
)
const (
DefaultSDKRoot = "/opt/android-sdk"
DefaultNDKRoot = "/opt/android-ndk"
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "internal/assets/keepassgo-icon.png"
)
type Config struct {
SDKRoot string
NDKRoot string
JavaHome string
AppID string
APKOut string
Version string
Ldflags string
MinSDK string
TargetSDK string
IconPath string
}
func DefaultConfig() Config {
return Config{
SDKRoot: DefaultSDKRoot,
NDKRoot: DefaultNDKRoot,
JavaHome: DefaultJavaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
}
}
func (c Config) GogioArgs() []string {
return []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",
}
}
func (c Config) Validate() error {
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
}
if !isDir(c.SDKRoot) {
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
}
if !isDir(c.NDKRoot) {
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
}
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
return fmt.Errorf("Android SDK cmdline-tools are missing")
}
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
}
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
return fmt.Errorf("Android build-tools are missing")
}
if !isFile(c.IconPath) {
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
}
return nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return info.Mode()&0o111 != 0
}
func isFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
-104
View File
@@ -1,104 +0,0 @@
package buildapk
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestDefaultConfigGogioArgs(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
want := []string{
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath,
"./cmd/keepassgo",
}
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
t.Fatalf("GogioArgs() = %v, want %v", got, want)
}
}
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
t.Parallel()
root := t.TempDir()
sdkRoot := filepath.Join(root, "sdk")
ndkRoot := filepath.Join(root, "ndk")
javaHome := filepath.Join(root, "java")
mkdirAll(t, filepath.Join(javaHome, "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
mkdirAll(t, ndkRoot)
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
cfg := Config{
SDKRoot: sdkRoot,
NDKRoot: ndkRoot,
JavaHome: javaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
}
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
}
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
t.Parallel()
root := t.TempDir()
cfg := Config{
SDKRoot: filepath.Join(root, "missing-sdk"),
NDKRoot: filepath.Join(root, "missing-ndk"),
JavaHome: filepath.Join(root, "missing-java"),
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want missing prerequisite error")
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
}
func makeExecutable(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
@@ -2,14 +2,14 @@ package clipboard
import (
"errors"
"fmt"
systemclipboard "github.com/atotto/clipboard"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/vault"
)
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
var ErrWriteFailed = errors.New("clipboard write failed")
type Target string
@@ -39,7 +39,7 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
return fmt.Errorf("write clipboard text: %w", err)
}
return nil
@@ -52,10 +52,6 @@ func (s Service) writer() Writer {
return systemWriter{}
}
func WriteText(text string) error {
return systemWriter{}.WriteText(text)
}
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == entryID {
@@ -83,19 +79,3 @@ type systemWriter struct{}
func (systemWriter) WriteText(text string) error {
return systemclipboard.WriteAll(text)
}
type writeError struct {
err error
}
func (e writeError) Error() string {
return ErrWriteFailed.Error()
}
func (e writeError) Unwrap() error {
return e.err
}
func (e writeError) Is(target error) bool {
return target == ErrWriteFailed
}
@@ -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,33 +60,14 @@ 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)
}
}
func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
t.Parallel()
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Password: "token-1"},
},
}
err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
if err.Error() != ErrWriteFailed.Error() {
t.Fatalf("Copy() write error string = %q, want %q", err.Error(), ErrWriteFailed.Error())
}
}
type memoryWriter struct {
content string
}
@@ -95,11 +76,3 @@ func (w *memoryWriter) WriteText(text string) error {
w.content = text
return nil
}
type failingWriter struct {
err error
}
func (w failingWriter) WriteText(string) error {
return w.err
}
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"runtime"
"strings"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"google.golang.org/grpc"
)
type bridgeConfig struct {
grpcAddr string
}
func main() {
cfg := bridgeConfig{
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
}
if len(os.Args) > 1 {
switch strings.TrimSpace(os.Args[1]) {
case "install-native-host":
if err := runInstallNativeHost(os.Args[2:]); err != nil {
fail(err)
}
return
case "status":
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
fail(err)
}
return
}
}
if err := runNativeMessage(cfg); err != nil {
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
os.Exit(1)
}
}
func runInstallNativeHost(args []string) error {
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
outputPath := fs.String("output", "", "native host manifest output path")
if err := fs.Parse(args); err != nil {
return err
}
path := strings.TrimSpace(*binaryPath)
if path == "" {
resolved, err := defaultBinaryPath()
if err != nil {
return err
}
path = resolved
}
resolvedExtensionID := strings.TrimSpace(*extensionID)
if resolvedExtensionID == "" {
keyValue := strings.TrimSpace(*extensionKey)
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
if err != nil {
return err
}
keyValue = string(data)
}
if keyValue != "" {
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
if err != nil {
return err
}
resolvedExtensionID = derivedID
}
}
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, installed)
return nil
}
func runStatus(cfg bridgeConfig, args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError)
token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil {
return err
}
req := browserbridge.Request{
Action: "status",
BearerToken: strings.TrimSpace(*token),
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func runNativeMessage(cfg bridgeConfig) error {
req, err := browserbridge.ReadRequest(os.Stdin)
if err != nil {
return err
}
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
defer func() { _ = conn.Close() }()
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
}
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
}
func defaultBinaryPath() (string, error) {
return browserbridge.ResolveBridgeBinaryPath("")
}
func resolveGlobalGRPCAddr(args []string) string {
addr := grpcaddr.Default(runtime.GOOS)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--grpc-addr="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
}
}
return addr
}
func stripGlobalGRPCAddrFlags(args []string) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
i++
continue
case strings.HasPrefix(arg, "--grpc-addr="):
continue
default:
out = append(out, args[i])
}
}
return out
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
-7
View File
@@ -1,7 +0,0 @@
package main
import "git.julianfamily.org/keepassgo/internal/appui"
func main() {
appui.Main()
}
-48
View File
@@ -1,48 +0,0 @@
# Accessibility Review
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
## What is intentionally supported
- Keyboard focus is explicit for:
- vault search
- breadcrumb navigation
- entry list selection
- detail/editor fields
- Focus styling is visible and distinct from the unfocused field treatment.
- Common keyboard workflows are covered in-repo by tests for:
- tab navigation
- list navigation
- search focus
- new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
## Current screen-reader boundary
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
- KeePassGO therefore treats screen-reader support as:
- label-conscious where the toolkit exposes focusable controls
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
## Current review result
- Linux:
- keyboard/focus behavior is intentionally supported
- visible focus states and control naming are present in code
- full Orca-style semantic verification is not something this repo can assert automatically today
- Windows:
- the same keyboard/focus behavior and explicit labels are present in-app
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
## What KeePassGO should continue doing
- Keep every major workflow operable without a pointer device.
- Add explicit labels for any new focusable control.
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
- Prefer dialogs and panels that keep keyboard focus predictable.
## What remains toolkit-limited
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
-78
View File
@@ -1,78 +0,0 @@
# Android Autofill
## App Target Matching
User story:
- When an entry carries an Android-specific target such as
`androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a
first-class autofill target on Android.
- If an exact app target exists, Android autofill should resolve that entry
directly instead of falling back to a generic chooser for the whole cache.
Expected behavior:
- `AndroidApp*` custom fields exported into the autofill cache must match the
Android package target used by the autofill and accessibility services.
- The Android-side matcher must normalize `androidapp://...` targets the same
way the Go cache builder does.
- The chooser path should still collapse to a single direct result when there
is one exact app-target match.
## Accessibility Fallback
User story:
- When Android accessibility fallback is needed, KeePassGO should not be
limited to Chrome-only URL bar parsing.
- Apps with stable package identities should still be fillable when an entry
carries a matching `AndroidApp*` target.
Expected behavior:
- Accessibility fallback derives its match target from the web domain when one
is available.
- If no web domain is available, accessibility fallback uses the active app
package as `androidapp://<package>`.
- The fallback path can therefore fill supported apps that never expose a
browser-style URL bar.
## Share-Driven Lookup
User story:
- When Android shares a login URL or a text snippet containing a login URL into
KeePassGO, the app should open into a credential lookup flow instead of only
supporting shared `.kdbx` imports.
- If the vault is already open, the shared target should immediately narrow the
entries view.
- If the vault is not open yet, the shared target should survive startup and
apply as soon as the vault is unlocked.
Expected behavior:
- Android share intents can queue a pending lookup target in addition to shared
vault file imports.
- KeePassGO normalizes the shared value into a search query that users can
immediately act on.
- The pending lookup is consumed once and does not keep reappearing on later
launches.
## Chooser Relevance
User story:
- When Android autofill cannot resolve a single direct match, KeePassGO should
still keep the chooser focused on entries that are relevant to the current
site or app.
- The picker should not fall back to an alphabetized dump of unrelated vault
entries when KeePassGO already knows the current host or package target.
Expected behavior:
- If multiple entries exactly match the current web host or Android app target,
the chooser shows only those relevant entries.
- If there are no exact matches but there are parent-host matches, the chooser
shows only those related entries.
- KeePassGO falls back to the full chooser list only when it has no related
host or app-target candidates at all.
-191
View File
@@ -1,191 +0,0 @@
# Browser Extension
KeePassGO browser integration uses:
- the existing local gRPC API in KeePassGO
- API tokens for authorization
- a tiny native messaging host for browser-to-gRPC transport adaptation
The browser extension does **not** talk to vault files directly.
## Security Model
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
- The browser extension stores the API token in browser extension storage.
- The native messaging host receives the token on each request from the extension.
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
- The native messaging host does not persist the token to disk.
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
## RPCs Used
The browser integration uses:
- `GetSessionStatus`
- `FindBrowserLogins`
- `GetBrowserCredential`
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
## Default Listener
On desktop KeePassGO listens on a Unix socket by default:
- primary location: under the user runtime directory
- fallback: `/run/user/<uid>` if present
- final fallback: a private directory under the system temp directory
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
```bash
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
```
## Native Host
Build the bridge:
```bash
go build ./cmd/keepassgo-browser-bridge
```
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
Install a Firefox native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
```
Install a Chromium native messaging manifest:
```bash
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
```
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
For a fixed Chromium ID:
1. Keep a stable Chromium extension signing key outside the repo.
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
## Extension Setup
Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page.
3. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome:
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
3. Configure the API token in the extension settings page.
## Current Browser Flow
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
- The toolbar badge shows when KeePassGO found matches for the current page.
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
## Search And Matching
User story:
- When a page has no obvious match, the popup still lets the user search the
vault without leaving the browser.
- Search results must stay scoped to what the current API token can actually
access.
- Browser matching must treat common KeePass data conventions as real browser
targets, not just the primary `URL` field.
Expected behavior:
- The popup exposes a `Search Vault` field that queries KeePassGO directly.
- Search results use the same fill path as page matches.
- Search never leaks entries outside the token's authorized group scope.
- A browser match can come from:
- the primary `URL` field
- scheme-less host values such as `gitlab.com`
- custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL
slots
## Locked Vault Workflow
User story:
- When the current page has a login form but KeePassGO is locked, the browser
must still make that state visible on the page and in the popup.
- Unlocking KeePassGO should not require the user to reopen the popup multiple
times or reload the page before the extension becomes usable again.
Expected behavior:
- The popup shows a locked-state message instead of silently falling back to
"no matches."
- The inline page affordance stays visible on login forms while KeePassGO is
locked and tells the user to unlock the vault.
- After the vault is unlocked, the extension rechecks the page automatically
and turns the locked affordance back into live matches without requiring a
page reload.
## Save And Update Workflow
User story:
- After the user submits a login form, the browser extension should help store
that credential instead of forcing the user back into KeePassGO manually.
- If KeePassGO already has a matching entry for that site and username, the
popup should offer an update.
- If the user is creating a new login, the popup should let the user save it
into a relevant vault group without leaving the browser.
Expected behavior:
- Submitted login forms queue a pending browser save/update state for the
active tab.
- The popup shows that pending save/update state prominently instead of hiding
it behind page matches alone.
- When KeePassGO finds an exact browser match for the submitted username and
site, the popup offers an `Update` action for that entry.
- When there is no exact entry match, the popup offers a `Save` action using a
relevant group path from the current page matches or a user-selected search
result.
- The browser save/update action writes through KeePassGO's existing secure
gRPC mutation API and stays scoped to the browser token's allowed groups.
For extension-side regression checks, run:
```bash
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
```
For a reproducible real-browser Chromium validation harness, run:
```bash
make browser-extension-validate
```
That target:
- validates the Firefox flow by default with a temporary addon install
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
- builds the native messaging bridge
- starts a stub KeePassGO gRPC server and a local login page
- drives the browser through inline match discovery, approval visibility, and fill completion
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
## Required Token Scope
At minimum, the browser token should have policy rules allowing:
- `list_entries` for the groups you want the browser to search
- `copy_username` for entries the browser may fill
- `copy_password` for entries the browser may fill
- `copy_url` for entries the browser may confirm against page URL
-207
View File
@@ -1,207 +0,0 @@
# GUI Test Plan
This document splits GUI validation into human-owned and agent-owned coverage.
The intent is that, together, both passes exercise every reachable GUI area
without relying on one side to judge both functional correctness and real
usability.
## Scope
- Desktop GUI
- Android GUI
- Shared workflow surfaces:
lifecycle, unlock, list/detail/editor, settings, sync, API tokens, API audit,
templates, recycle bin, About, and platform-specific integrations
## Ownership Model
- Human testing covers visual correctness, ergonomics, discoverability, focus,
scrolling, tap targets, keyboard feel, and platform integration behavior.
- Agent testing covers deterministic automation:
build, install, launch, focus, test suite, lint, and release-path validation.
## Android Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and first render | Verify real rendering, layout, readability, no black screen | Verify emulator online, app installed, app focused |
| Lifecycle screen | Exercise create/open/open-remote forms visually | Cover lifecycle logic through Go tests and release APK build |
| Unlock flow | Verify password/key-file affordances, focus, error messaging | Covered by automated tests |
| Vault list and navigation | Verify scrolling, selection, section switching, phone layout reachability | Covered by automated tests for list state/search/section behavior |
| Entry detail | Verify readability, action placement, tap targets | Covered by automated tests for state transitions and copy/generate actions |
| Entry editor | Verify field editing usability and save affordance | Covered by automated tests for save/update behavior |
| Template views | Verify template list/editor reachability and copy | Covered by automated tests |
| Recycle bin | Verify deleted-entry browsing/search UX | Covered by automated tests |
| Attachments UI | Verify list/add/remove affordances visually | Covered by automated tests for attachment summaries and save paths |
| Search | Verify placeholder, live filtering feel, clear behavior | Covered by automated tests for search semantics |
| Settings | Verify toggles and summaries are understandable | Covered by automated tests for persistence |
| Remote sync setup/dialog | Verify sheet/dialog layout, field order, source/direction controls | Covered by automated tests and release build |
| Saved remote binding UI | Verify summaries are understandable and discoverable | Covered by automated tests |
| API tokens / API audit | Verify navigation and dense-detail readability | Covered by automated tests |
| About / informational screens | Verify copy, scroll, layout | Covered by automated tests for search disable state and section state |
| Android-only share/import/file picker | Verify system integration actually works | Agent only verifies package/build/focus, not picker UX |
| Android autofill entry point | Verify Android mechanism appears and is usable | Agent only verifies app/package/focus |
### Human Steps
1. Start from the currently installed `org.julianfamily.keepassgo` app on the
running emulator or device.
2. Verify launch lands on a rendered screen, not a black frame, blank frame, or
frozen frame.
3. On the lifecycle screen, inspect every visible action:
`Create vault`, `Open vault`, `Open remote vault`, unlock-related controls,
recent vaults, and any sync summaries.
4. Create a throwaway vault or open a demo vault and verify the full unlock path
is visually clear.
5. After unlock, visit each top-level section you can reach in the Android UI:
vault/group list, entry detail, entry editor, templates, recycle bin,
settings, API tokens, API audit, About.
6. In the main vault UI, verify:
group navigation, entry selection, search, back behavior, scrolling, and any
phone-only toggles or drawers.
7. Open an entry and exercise every visible entry action:
copy username, copy password, copy URL, reveal/hide password, generate
password, save.
8. Exercise one template flow:
open template list, inspect template detail/editor, save or cancel.
9. Exercise recycle-bin browsing and search.
10. Open settings and inspect every visible toggle or summary card. Change at
least one benign toggle and verify it sticks after leaving and returning.
11. Open the sync UI and inspect:
remote setup, saved-binding summary, direction/source choices,
confirm/cancel behavior, and text summaries.
12. Exercise one Android-native integration:
shared-vault import, file picker open, or current-vault share.
13. If Android autofill is in scope for current testing, open a target app/site
and confirm the Android autofill entry point is offered and usable.
14. Record failures with:
exact screen, exact control, expected behavior, actual behavior, and whether
it is Android-only or likely shared with desktop.
### Agent Steps Executed
1. Verified emulator availability with `adb devices -l`.
2. Verified emulator Android version with
`adb shell getprop ro.build.version.release`.
3. Verified app package installed with
`adb shell pm list packages org.julianfamily.keepassgo`.
4. Verified app focus with
`adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'`.
5. Ran `go test ./...`.
6. Ran `go tool golangci-lint run ./...`.
7. Verified release APK path through release workflow `v0.6.0`:
PR run `60` succeeded,
post-merge `main` run `61` succeeded,
tag run `62` succeeded.
### Agent Results
- Emulator present: yes
- Android version: `15`
- App installed: yes
- App focused:
`org.julianfamily.keepassgo/org.gioui.GioActivity`
- APK release build path: passed in release CI
- Remaining Android risk:
real visual usability and system-integration behavior
## Desktop Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and window presentation | Verify startup polish, focus, resize behavior, DPI/readability | Verify desktop binaries built in release CI |
| Lifecycle screen | Verify desktop layout density, affordance clarity, recent vault usability | Covered by automated tests |
| Unlock flow | Verify keyboard-first behavior, focus order, errors, lock/unlock loop | Covered by automated tests plus human keyboard feel |
| Group browser and list pane | Verify density, selection clarity, scrolling, split behavior | Covered by automated tests for state/search |
| Entry detail pane | Verify copy actions, reveal/hide, path context, readability | Covered by automated tests |
| Entry editor | Verify field order, keyboard flow, save/cancel usability | Covered by automated tests |
| Templates | Verify templates section is reachable and understandable | Covered by automated tests |
| Recycle bin | Verify browsing, searching, deletion or recovery UX | Covered by automated tests |
| Search | Verify keyboard search flow, placeholder correctness, result context | Covered by automated tests |
| Vault save/save-as/lock | Verify menus, buttons, and shortcuts feel correct | Covered by automated tests and release builds |
| Settings | Verify desktop layout, summaries, toggles, persistence feel | Covered by automated tests |
| Remote sync UI | Verify dialog layout, wording, discoverability, advanced options | Covered by automated tests |
| API tokens | Verify dense-detail presentation and policy editor usability | Covered by automated tests |
| API audit | Verify search/filter/readability | Covered by automated tests |
| Browser-extension-adjacent desktop UX | Verify visible status/help text and extension workflow discoverability | Agent validated release/build path; human should judge usability |
| About and docs entry points | Verify copy and layout | Covered partly by tests; human judges presentation |
### Human Steps
1. Launch KeePassGO on desktop from the shipped binary or normal desktop entry.
2. Inspect the lifecycle/open screen at normal window size and at a narrower
width.
3. Exercise create, open, save-as, lock, unlock, and reopen on a throwaway
vault or demo vault.
4. Use keyboard-first operation for at least one complete pass:
tab order, enter or escape expectations, search focus, unlock focus, and
editor save flow.
5. After unlocking, visit every reachable primary section:
vault list, entry detail, editor, templates, recycle bin, settings,
API tokens, API audit, About.
6. In the vault browser, verify:
nested groups, path context, scrolling, selection state, and search results
with path context.
7. Open an entry and exercise all visible actions:
copy username, password, URL, reveal/hide password, password generation,
and save.
8. Exercise at least one mutation flow:
create entry, edit entry, move/delete entry, view recycle bin, and recover
if available.
9. Open settings and inspect all visible summaries and toggles.
10. Open remote sync UI and inspect every visible mode:
setup, saved binding, advanced sync, source or direction choices.
11. Open API Tokens and API Audit even if you do not issue a real token, just
to assess navigation and readability.
12. If you use browser integration, verify the desktop-side flow is still
understandable from the product UI and extension behavior.
13. Record failures with:
screen, control, expected behavior, actual behavior, and whether it is
presentation-only or functional.
### Agent Steps Executed
1. Verified clean repo state on `main`.
2. Ran `go test ./...`.
3. Ran `go tool golangci-lint run ./...`.
4. Verified post-merge `main` CI run `61` succeeded.
5. Verified release tag run `62` succeeded.
6. Verified release `v0.6.0` published.
7. Verified release artifacts include:
`keepassgo-linux-amd64`,
`keepassgo-windows-amd64.exe`,
`keepassgo-windows-arm64.exe`,
and `keepassgo.apk`.
### Agent Results
- Automated logic/state coverage: pass
- Desktop build coverage: pass
- Release publication: pass
- Remaining desktop risk:
interaction quality, keyboard feel, dense-layout readability, and workflow
discovery
## Reporting Template
Use this format when reporting findings from the human pass:
```md
## Android
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
## Desktop
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
```
+3 -8
View File
@@ -5,14 +5,9 @@ KeePassGO supports the following KDBX security workflows today:
- open and save password-only vaults
- open and save key-file-only vaults
- open and save composite password-plus-key-file vaults
- select the active master-key mode in the product UI for create, open, and unlock flows
- change an existing session to a new master-key mode before saving
- preserve the original opened vault's KDBX format version during save
- preserve the original opened vault's cipher selection during save
- preserve the original opened vault's KDF selection during save
- choose the cipher family for new vault creation
- choose the KDF family for new vault creation
- change the cipher family and KDF family for an existing unlocked session before the next save
What "preserve" means:
@@ -21,11 +16,11 @@ What "preserve" means:
Current explicit limitations:
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
- new vault creation still uses the library default KDBX header settings for freshly created databases
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
Practical expectation:
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
- KeePassGO does not yet try to be a full advanced database-settings editor
-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 -13
View File
@@ -2,13 +2,8 @@ module git.julianfamily.org/keepassgo
go 1.26
replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc
replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc
require (
gioui.org v0.9.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -19,9 +14,7 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
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
@@ -33,7 +26,6 @@ require (
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/akavel/rsrc v0.10.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
@@ -78,7 +70,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
@@ -216,7 +207,4 @@ require (
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
tool (
gioui.org/cmd/gogio
github.com/golangci/golangci-lint/cmd/golangci-lint
)
tool github.com/golangci/golangci-lint/cmd/golangci-lint
+2 -26
View File
@@ -37,17 +37,11 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/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.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc h1:jyfCTx9wk/uLaEMkdKsg491C/kjfbG2EKAVTORhZxHo=
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc h1:p2AaZUAXa/ExPybNyeB05+GjTSZGA9lCfDpWz49IT5Y=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc/go.mod h1:BdI7mF5DCa3kxlo3G93XHL7khtZnk1gu4335pExk8gs=
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=
@@ -72,8 +66,6 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
@@ -132,12 +124,6 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -182,8 +168,6 @@ github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4a
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -228,14 +212,6 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
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=
-172
View File
@@ -1,172 +0,0 @@
package api
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
)
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
socketPath string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
addr = strings.TrimSpace(addr)
if addr == "" || strings.EqualFold(addr, "off") {
return nil, nil
}
network, endpoint, err := grpcaddr.Parse(addr)
if err != nil {
return nil, err
}
listener, socketPath, err := listen(network, endpoint)
if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
}
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{
server: service,
grpcServer: server,
listener: listener,
lifecycle: lifecycle,
dirty: dirty,
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
socketPath: socketPath,
started: true,
}
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
_ = listener.Close()
server.Stop()
return nil, err
}
go func() {
_ = server.Serve(listener)
}()
return host, nil
}
func (h *Host) Address() string {
if h == nil {
return ""
}
return h.listenAddr
}
func (h *Host) Server() *Server {
if h == nil {
return nil
}
return h.server
}
func (h *Host) Stop() error {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
if !h.started {
return nil
}
h.started = false
h.grpcServer.Stop()
err := h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr
}
}
return err
}
func (h *Host) SyncFromLifecycle() error {
if h == nil || h.lifecycle == nil || h.server == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
model, err := h.lifecycle.Current()
locked := false
switch {
case err == nil:
h.lastModel = model
case errors.Is(err, session.ErrLocked):
locked = true
default:
return err
}
dirty := false
if h.dirty != nil {
dirty = h.dirty()
}
h.server.SetSessionState(h.lastModel, locked, dirty)
return nil
}
func listen(network, endpoint string) (net.Listener, string, error) {
if network == "unix" {
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
return nil, "", err
}
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
listener, err := net.Listen("unix", endpoint)
if err != nil {
return nil, "", err
}
if err := os.Chmod(endpoint, 0o600); err != nil {
_ = listener.Close()
return nil, "", err
}
return listener, endpoint, nil
}
listener, err := net.Listen(network, endpoint)
if err != nil {
return nil, "", err
}
return listener, "", nil
}
func formatListenAddress(network, listenerAddr, socketPath string) string {
if network == "unix" {
return "unix://" + socketPath
}
return listenerAddr
}
-123
View File
@@ -1,123 +0,0 @@
package api
import (
"context"
"errors"
"net"
"os"
"testing"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Parallel()
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationManageVault,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root"},
},
},
),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
defer func() { _ = host.Stop() }()
network, endpoint, err := grpcaddr.Parse(host.Address())
if err != nil {
t.Fatalf("Parse(host.Address()) error = %v", err)
}
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial(network, endpoint)
}),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
defer func() { _ = conn.Close() }()
client := keepassgov1.NewVaultServiceClient(conn)
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
if !statusResp.Dirty {
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
}
if err := lifecycle.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if err := host.SyncFromLifecycle(); err != nil {
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
}
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
}
}
func TestStartHostServesOverUnixSocket(t *testing.T) {
t.Parallel()
socketDir := t.TempDir()
socketPath := socketDir + "/keepassgo.sock"
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
if got := host.Address(); got != "unix://"+socketPath {
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
}
if _, err := os.Stat(socketPath); err != nil {
t.Fatalf("Stat(socketPath) error = %v", err)
}
if err := host.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-234
View File
@@ -1,234 +0,0 @@
package apiapproval
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
)
type Outcome string
const (
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
)
type Request struct {
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
RequestedAt time.Time
}
type Result struct {
Outcome Outcome
Rule *apitokens.PolicyRule
}
type Broker struct {
mu sync.Mutex
pending map[string]*pendingRequest
timeout time.Duration
now func() time.Time
nextID func() string
notify func()
}
type pendingRequest struct {
request Request
done chan Outcome
}
type idGenerator struct {
mu sync.Mutex
counter int
}
func NewBroker(timeout time.Duration) *Broker {
gen := &idGenerator{}
return &Broker{
pending: map[string]*pendingRequest{},
timeout: timeout,
now: func() time.Time {
return time.Now().UTC()
},
nextID: func() string {
return gen.Next()
},
}
}
func (g *idGenerator) Next() string {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
return "approval-" + strconv.Itoa(g.counter)
}
func (b *Broker) Pending() []Request {
b.mu.Lock()
defer b.mu.Unlock()
requests := make([]Request, 0, len(b.pending))
for _, pending := range b.pending {
requests = append(requests, pending.request)
}
slices.SortFunc(requests, func(a, c Request) int {
switch {
case a.RequestedAt.Before(c.RequestedAt):
return -1
case a.RequestedAt.After(c.RequestedAt):
return 1
case a.ID < c.ID:
return -1
case a.ID > c.ID:
return 1
default:
return 0
}
})
return requests
}
func (b *Broker) SetChangeNotifier(notify func()) {
if b == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.notify = notify
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrBrokerNotConfigured
}
pending := &pendingRequest{
request: Request{
ID: b.nextID(),
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
RequestedAt: b.now(),
},
done: make(chan Outcome, 1),
}
b.mu.Lock()
b.pending[pending.request.ID] = pending
notify := b.notify
b.mu.Unlock()
if notify != nil {
notify()
}
defer func() {
b.mu.Lock()
delete(b.pending, pending.request.ID)
notify := b.notify
b.mu.Unlock()
if notify != nil {
notify()
}
}()
timer := time.NewTimer(b.timeout)
defer timer.Stop()
select {
case outcome := <-pending.done:
result, err := resultForOutcome(pending.request, outcome)
if err != nil {
return result, err
}
return result, nil
case <-timer.C:
return Result{}, ErrRequestTimedOut
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
b.mu.Lock()
pending, ok := b.pending[id]
b.mu.Unlock()
if !ok {
return Request{}, nil, ErrRequestNotFound
}
rule, err := RuleFromDecision(pending.request, outcome)
if err != nil {
return Request{}, nil, err
}
select {
case pending.done <- outcome:
default:
}
return pending.request, rule, nil
}
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
rule, err := RuleFromDecision(request, outcome)
if err != nil {
return Result{}, err
}
result := Result{Outcome: outcome, Rule: rule}
switch outcome {
case OutcomeAllowOnce, OutcomeAllowPermanent:
return result, nil
case OutcomeDenyOnce, OutcomeDenyPermanent:
return result, ErrRequestDenied
case OutcomeCancel:
return result, ErrRequestCanceled
default:
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
switch outcome {
case OutcomeAllowPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeDenyPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectDeny,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
return nil, nil
default:
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
-175
View File
@@ -1,175 +0,0 @@
package apiapproval
import (
"context"
"errors"
"slices"
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
resultCh := make(chan Result, 1)
errCh := make(chan error, 1)
go func() {
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
resultCh <- result
errCh <- err
}()
waitForPending(t, broker, 1)
pending := broker.Pending()
if len(pending) != 1 {
t.Fatalf("Pending() len = %d, want 1", len(pending))
}
if pending[0].TokenID != "token-1" {
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
}
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
result := <-resultCh
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
if result.Outcome != OutcomeAllowOnce {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
}
if result.Rule != nil {
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
}
}
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
reqDone := make(chan struct{})
var result Result
var err error
go func() {
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
close(reqDone)
}()
waitForPending(t, broker, 1)
pending := broker.Pending()[0]
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
if resolveErr != nil {
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
}
if request.ID != pending.ID {
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
}
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
}
<-reqDone
if !errors.Is(err, ErrRequestDenied) {
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
}
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
}
if result.Outcome != OutcomeDenyPermanent {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
}
}
func TestBrokerSupportsCancellation(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
t.Fatalf("Resolve(cancel) error = %v", err)
}
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
}
}
func TestBrokerTimesOutPendingRequests(t *testing.T) {
t.Parallel()
broker := NewBroker(10 * time.Millisecond)
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrRequestTimedOut) {
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
}
}
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
t.Parallel()
var broker *Broker
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrBrokerNotConfigured) {
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
}
}
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
changes := make(chan int, 4)
broker.SetChangeNotifier(func() {
changes <- len(broker.Pending())
})
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
got := []int{<-changes, <-changes}
slices.Sort(got)
if !slices.Equal(got, []int{0, 1}) {
t.Fatalf("change notifications = %v, want [0 1]", got)
}
}
func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := len(broker.Pending()); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("Pending() never reached len %d", want)
}
-86
View File
@@ -1,86 +0,0 @@
package apiaudit
import (
"slices"
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
type EventType string
const (
EventApprovalRequested EventType = "approval_requested"
EventApprovalAllowed EventType = "approval_allowed"
EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out"
EventTokenIssued EventType = "token_issued"
EventTokenUpdated EventType = "token_updated"
EventTokenRotated EventType = "token_rotated"
EventTokenDisabled EventType = "token_disabled"
EventTokenRevoked EventType = "token_revoked"
EventTokenDeleted EventType = "token_deleted"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected"
)
type Event struct {
Type EventType
At time.Time
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
Message string
}
type Log struct {
mu sync.Mutex
max int
now func() time.Time
events []Event
}
func New(max int) *Log {
if max < 1 {
max = 1
}
return &Log{
max: max,
now: func() time.Time {
return time.Now().UTC()
},
}
}
func (l *Log) Record(event Event) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if event.At.IsZero() {
event.At = l.now()
}
l.events = append([]Event{event}, l.events...)
if len(l.events) > l.max {
l.events = l.events[:l.max]
}
}
func (l *Log) Events() []Event {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
return slices.Clone(l.events)
}
-68
View File
@@ -1,68 +0,0 @@
package apiaudit
import (
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
t.Parallel()
log := New(2)
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
events := log.Events()
if len(events) != 2 {
t.Fatalf("len(Events()) = %d, want 2", len(events))
}
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
}
}
func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventApprovalRequested,
TokenID: "token-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
Message: "prompted for access",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
}
}
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
-338
View File
@@ -1,338 +0,0 @@
package apitokens
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
const (
EntryTypeAPIToken = "api-token"
FieldType = "KeePassGO-Type"
FieldTokenID = "KeePassGO-API-Token-ID"
FieldClientName = "KeePassGO-API-Client-Name"
FieldCreatedAt = "KeePassGO-API-Created-At"
FieldExpiresAt = "KeePassGO-API-Expires-At"
FieldDisabled = "KeePassGO-API-Disabled"
FieldRevokedAt = "KeePassGO-API-Revoked-At"
FieldSecretHash = "KeePassGO-API-Secret-Hash"
FieldPolicies = "KeePassGO-API-Policies"
)
var (
ErrNotAToken = errors.New("entry is not an api token")
ErrInvalidToken = errors.New("invalid api token")
ErrExpiredToken = errors.New("expired api token")
ErrDisabledToken = errors.New("disabled api token")
ErrTokenNotFound = errors.New("api token not found")
)
var EntryPath = []string{"Root", "API Tokens"}
type Effect string
type Operation string
type ResourceKind string
type Decision string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
ResourceGroup ResourceKind = "group"
ResourceEntry ResourceKind = "entry"
DecisionAllow Decision = "allow"
DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationListTemplates Operation = "list_templates"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationMutateTemplate Operation = "mutate_template"
OperationGeneratePassword Operation = "generate_password"
OperationManageVault Operation = "manage_vault"
)
type Resource struct {
Kind ResourceKind `json:"kind"`
Path []string `json:"path,omitempty"`
EntryID string `json:"entry_id,omitempty"`
}
type PolicyRule struct {
Effect Effect `json:"effect"`
Operation Operation `json:"operation"`
Resource Resource `json:"resource"`
}
type Token struct {
ID string
Name string
ClientName string
SecretHash string
CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
Disabled bool
Policies []PolicyRule
}
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
id, _, err := newSecret()
if err != nil {
return Token{}, "", err
}
return Token{
ID: id,
Name: strings.TrimSpace(name),
ClientName: strings.TrimSpace(clientName),
SecretHash: hashed,
CreatedAt: now.UTC(),
ExpiresAt: cloneTime(expiresAt),
}, clear, nil
}
func Rotate(token Token, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
token.SecretHash = hashed
token.Disabled = false
token.RevokedAt = nil
if token.CreatedAt.IsZero() {
token.CreatedAt = now.UTC()
}
return token, clear, nil
}
func Disable(token Token) Token {
token.Disabled = true
return token
}
func Revoke(token Token, when time.Time) Token {
token.Disabled = true
t := when.UTC()
token.RevokedAt = &t
return token
}
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
hashed := hashSecret(presentedSecret)
for _, token := range tokens {
if token.SecretHash != hashed {
continue
}
if token.Disabled || token.RevokedAt != nil {
return Token{}, ErrDisabledToken
}
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
return Token{}, ErrExpiredToken
}
return token, nil
}
return Token{}, ErrInvalidToken
}
func Evaluate(token Token, operation Operation, resource Resource) Decision {
decision := DecisionPrompt
for _, rule := range token.Policies {
if rule.Operation != operation {
continue
}
if !matches(rule.Resource, resource) {
continue
}
if rule.Effect == EffectDeny {
return DecisionDeny
}
if rule.Effect == EffectAllow {
decision = DecisionAllow
}
}
return decision
}
func Entries(model vault.Model) ([]Token, error) {
var out []Token
for _, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return nil, err
}
if ok {
out = append(out, token)
}
}
slices.SortFunc(out, func(a, b Token) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
default:
return strings.Compare(a.ID, b.ID)
}
})
return out, nil
}
func Find(model vault.Model, id string) (Token, error) {
tokens, err := Entries(model)
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if token.ID == id {
return token, nil
}
}
return Token{}, ErrTokenNotFound
}
func Upsert(model *vault.Model, token Token) {
model.UpsertEntry(token.Entry(EntryPath))
model.CreateGroup([]string{"Root"}, "API Tokens")
}
func Delete(model *vault.Model, id string) error {
for i, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return err
}
if ok && token.ID == id {
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
return nil
}
}
return ErrTokenNotFound
}
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
if entry.Fields[FieldType] != EntryTypeAPIToken {
return Token{}, false, nil
}
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
if err != nil {
return Token{}, true, fmt.Errorf("parse created at: %w", err)
}
var expiresAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
}
expiresAt = &t
}
var revokedAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
}
revokedAt = &t
}
policies := []PolicyRule{}
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
return Token{}, true, fmt.Errorf("parse policies: %w", err)
}
}
return Token{
ID: entry.Fields[FieldTokenID],
Name: entry.Title,
ClientName: entry.Fields[FieldClientName],
SecretHash: entry.Fields[FieldSecretHash],
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RevokedAt: revokedAt,
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
Policies: policies,
}, true, nil
}
func (t Token) Entry(path []string) vault.Entry {
fields := map[string]string{
FieldType: EntryTypeAPIToken,
FieldTokenID: t.ID,
FieldClientName: t.ClientName,
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
FieldSecretHash: t.SecretHash,
}
if t.ExpiresAt != nil {
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
}
if t.RevokedAt != nil {
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
}
if len(t.Policies) > 0 {
data, _ := json.Marshal(t.Policies)
fields[FieldPolicies] = string(data)
}
return vault.Entry{
ID: t.ID,
Title: t.Name,
Username: t.ClientName,
Path: slices.Clone(path),
Fields: fields,
}
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func newSecret() (string, string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate secret: %w", err)
}
clear := base64.RawURLEncoding.EncodeToString(buf)
return clear, hashSecret(clear), nil
}
func cloneTime(in *time.Time) *time.Time {
if in == nil {
return nil
}
t := in.UTC()
return &t
}
func matches(rule, resource Resource) bool {
switch rule.Kind {
case ResourceEntry:
return rule.EntryID != "" && rule.EntryID == resource.EntryID
case ResourceGroup:
if len(rule.Path) > len(resource.Path) {
return false
}
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
default:
return false
}
}
-282
View File
@@ -1,282 +0,0 @@
package apitokens
import (
"slices"
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
token := Token{
ID: "token-1",
Name: "Browser Connector",
ClientName: "browser-extension",
SecretHash: "deadbeef",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
ExpiresAt: &expiresAt,
Disabled: true,
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
},
}
entry := token.Entry([]string{"Root", "API Tokens"})
if entry.Fields[FieldType] != EntryTypeAPIToken {
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
}
got, ok, err := TokenFromEntry(entry)
if err != nil {
t.Fatalf("TokenFromEntry() error = %v", err)
}
if !ok {
t.Fatal("TokenFromEntry() ok = false, want true")
}
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
}
if len(got.Policies) != 2 {
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
}
}
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Ordinary Entry"},
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
},
}
got, err := Entries(model)
if err != nil {
t.Fatalf("Entries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(Entries()) = %d, want 2", len(got))
}
if got[0].Name != "Browser" || got[1].Name != "CLI" {
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
}
}
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
t.Parallel()
model := vault.Model{}
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "grpc-cli",
SecretHash: "hash-1",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
}
Upsert(&model, token)
if len(model.Entries) != 1 {
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
}
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
}
token.ClientName = "rotated-client"
token.Disabled = true
Upsert(&model, token)
got, err := Find(model, "token-1")
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if got.ClientName != "rotated-client" || !got.Disabled {
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
}
}
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
{ID: "entry-1", Title: "Regular Entry"},
},
}
if err := Delete(&model, "token-1"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
}
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
}
}
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if token.ID == "" || secret == "" {
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
}
if token.SecretHash == secret {
t.Fatal("SecretHash should not equal cleartext secret")
}
if token.Disabled {
t.Fatal("Disabled = true, want false after Issue")
}
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("Rotate() error = %v", err)
}
if rotated.ID != token.ID {
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
}
if newSecret == secret {
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
}
if rotated.SecretHash == token.SecretHash {
t.Fatal("Rotate() left SecretHash unchanged")
}
disabled := Disable(rotated)
if !disabled.Disabled {
t.Fatal("Disable() did not set Disabled")
}
revoked := Revoke(disabled, now.Add(2*time.Hour))
if !revoked.Disabled {
t.Fatal("Revoke() should leave token disabled")
}
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
}
}
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(expired) error = %v", err)
}
expiredAt := now.Add(-time.Minute)
expired.ExpiresAt = &expiredAt
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(disabled) error = %v", err)
}
disabled = Disable(disabled)
tokens := []Token{expired, disabled, valid}
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
}
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
}
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
}
got, err := Authenticate(tokens, secret, now)
if err != nil {
t.Fatalf("Authenticate(valid) error = %v", err)
}
if got.ID != valid.ID {
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
}
}
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
},
}
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
if decision != DecisionAllow {
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
if decision != DecisionDeny {
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
if decision != DecisionPrompt {
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
}
}
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
},
}
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
if allowed != DecisionAllow || denied != DecisionDeny {
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
}
}
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "cli",
SecretHash: "hash",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
Policies: []PolicyRule{
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
},
}
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
if err != nil || !ok {
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
}
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
}) {
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
}
}
-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)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-107
View File
@@ -1,107 +0,0 @@
package api
import (
"strings"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
type AuditQuickFilter struct {
Label string
Query string
}
func Operations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationListTemplates,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationMutateTemplate,
apitokens.OperationGeneratePassword,
apitokens.OperationManageVault,
}
}
func AuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func AuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func CompactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func AuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
AuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
AuditOperationLabel(event.Operation),
FormatResourcePath(event.Resource.Path),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
File diff suppressed because it is too large Load Diff
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,
}
}
-518
View File
@@ -1,518 +0,0 @@
package appui
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) attachmentInput() (string, []byte, error) {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "", nil, fmt.Errorf("attachment name is required")
}
path := strings.TrimSpace(u.attachmentPath.Text())
if path == "" {
return "", nil, fmt.Errorf("attachment path is required")
}
info, err := os.Stat(path)
if err != nil {
return "", nil, fmt.Errorf("stat attachment: %w", err)
}
if info.Size() > maxAttachmentBytes {
return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes)
}
content, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("read attachment: %w", err)
}
return name, content, nil
}
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
item, ok := u.selectedEntry()
if !ok {
u.entryID.SetText("")
u.entryTitle.SetText("")
u.entryUsername.SetText("")
u.entryPassword.SetText("")
u.entryURL.SetText("")
u.entryNotes.SetText("")
u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.entryFields.SetText("")
u.setCustomFieldRows(nil)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
return
}
u.entryID.SetText(item.ID)
u.entryTitle.SetText(item.Title)
u.entryUsername.SetText(item.Username)
u.entryPassword.SetText(item.Password)
u.entryURL.SetText(item.URL)
u.entryNotes.SetText(item.Notes)
u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
u.entryFields.SetText(marshalFields(item.Fields))
u.setCustomFieldRows(item.Fields)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
}
func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
u.appendCustomFieldRow(key, fields[key])
}
}
func (u *ui) appendCustomFieldRow(key, value string) {
keyEditor := widget.Editor{SingleLine: true, Submit: false}
keyEditor.SetText(key)
valueEditor := widget.Editor{SingleLine: true, Submit: false}
valueEditor.SetText(value)
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
value := strings.TrimSpace(u.customFieldValues[i].Text())
if key == "" && value == "" {
continue
}
if key == "" {
return nil, fmt.Errorf("custom field name is required")
}
fields[key] = value
}
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
return parseFields(u.entryFields.Text())
}
if len(fields) == 0 {
return nil, nil
}
return fields, nil
}
func (u *ui) visibleHistory() []vault.Entry {
item, ok := u.selectedEntry()
if !ok || len(item.History) == 0 {
return nil
}
return append([]vault.Entry(nil), item.History...)
}
func (u *ui) selectedHistoryEntry() (vault.Entry, bool) {
history := u.visibleHistory()
if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) {
return vault.Entry{}, false
}
return history[u.selectedHistoryIndex], true
}
func (u *ui) selectHistoryVersion(index int) error {
history := u.visibleHistory()
if index < 0 || index >= len(history) {
return fmt.Errorf("history index %d out of range", index)
}
u.selectedHistoryIndex = index
u.historyIndex.SetText(strconv.Itoa(index))
return nil
}
func (u *ui) saveEntryAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
func (u *ui) duplicateSelectedEntryAction() error {
baseID := strings.TrimSpace(u.state.SelectedEntryID)
if baseID == "" {
return fmt.Errorf("no entry selected")
}
duplicateID := baseID + "-copy"
if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) deleteSelectedEntryAction() error {
if err := u.state.DeleteSelectedEntry(); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedRecycleEntryAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no recycle-bin entry selected")
}
if err := u.state.RestoreEntry(id); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveTemplateAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if len(entry.Path) == 0 {
entry.Path = []string{"Templates"}
}
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
func (u *ui) deleteSelectedTemplateAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no template selected")
}
if err := u.state.DeleteTemplate(id); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) createGroupAction() error {
u.clearDeleteGroupConfirmation()
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()))
}
func (u *ui) deleteCurrentGroupAction() error {
if !u.deleteGroupPendingConfirmation() {
return fmt.Errorf("confirm deleting the empty group first")
}
if err := u.state.DeleteCurrentGroup(); err != nil {
return err
}
u.clearDeleteGroupConfirmation()
u.adoptStateCurrentPath()
u.filter()
return nil
}
func (u *ui) instantiateSelectedTemplateAction() error {
templateID := strings.TrimSpace(u.state.SelectedEntryID)
if templateID == "" {
return fmt.Errorf("no template selected")
}
entry, err := u.editorEntry()
if err != nil {
return err
}
if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) addAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
}
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) replaceAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
}
if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) exportAttachmentAction() error {
item, ok := u.selectedEntry()
if !ok {
return fmt.Errorf("no entry selected")
}
name := strings.TrimSpace(u.attachmentName.Text())
content, ok := item.Attachments[name]
if !ok {
return fmt.Errorf("attachment not found")
}
exportPath := strings.TrimSpace(u.exportAttachmentPath.Text())
if exportPath == "" {
return fmt.Errorf("export attachment path is required")
}
if err := os.WriteFile(exportPath, content, 0o600); err != nil {
return fmt.Errorf("write attachment export: %w", err)
}
return nil
}
func (u *ui) removeAttachmentAction() error {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedHistoryAction() error {
index, err := u.selectedHistoryVersionIndex()
if err != nil {
return err
}
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) selectedHistoryVersionIndex() (int, error) {
text := strings.TrimSpace(u.historyIndex.Text())
if text != "" {
index, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("invalid history index: %w", err)
}
return index, nil
}
if u.selectedHistoryIndex >= 0 {
return u.selectedHistoryIndex, nil
}
return 0, fmt.Errorf("no history version selected")
}
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
return err
}
password, err := passwords.Generate(profile)
if err != nil {
return err
}
u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil
}
func (u *ui) editorEntry() (vault.Entry, error) {
path := parsePath(u.entryPath.Text())
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
path = append([]string{root}, path...)
}
fields, err := u.currentCustomFields()
if err != nil {
return vault.Entry{}, err
}
return vault.Entry{
ID: strings.TrimSpace(u.entryID.Text()),
Title: strings.TrimSpace(u.entryTitle.Text()),
Username: strings.TrimSpace(u.entryUsername.Text()),
Password: u.entryPassword.Text(),
URL: strings.TrimSpace(u.entryURL.Text()),
Notes: strings.TrimSpace(u.entryNotes.Text()),
Tags: parseTags(u.entryTags.Text()),
Path: path,
Fields: fields,
}, nil
}
func parsePath(text string) []string {
var out []string
for _, part := range strings.Split(text, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseTags(text string) []string {
var out []string
for _, part := range strings.Split(text, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseFields(text string) (map[string]string, error) {
if strings.TrimSpace(text) == "" {
return nil, nil
}
fields := map[string]string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid field line %q", line)
}
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return fields, nil
}
func marshalFields(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
lines := make([]string, 0, len(fields))
for key, value := range fields {
lines = append(lines, key+"="+value)
}
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)
}
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,
}
}
-442
View File
@@ -1,442 +0,0 @@
package appui
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"
)
type focusID string
type detailField = editormodel.Field
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"
)
func breadcrumbFocusID(index int) focusID {
return focusID(fmt.Sprintf("breadcrumb:%d", index))
}
func listFocusID(index int) focusID {
return focusID(fmt.Sprintf("list:%d", index))
}
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()
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.startOpenRemoteAction()
} else {
u.startOpenVaultAction()
}
return true
}
switch name {
case key.NameTab:
delta := 1
if modifiers.Contain(key.ModShift) {
delta = -1
}
u.moveKeyboardFocus(delta)
return true
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
return u.handleFocusedKey(name)
default:
return false
}
}
func (u *ui) moveKeyboardFocus(delta int) {
order := u.focusOrder()
if len(order) == 0 {
return
}
current := canonicalFocusID(u.keyboardFocus)
index := 0
for i, item := range order {
if canonicalFocusID(item) == current {
index = i
break
}
}
index += delta
if index < 0 {
index = len(order) - 1
}
if index >= len(order) {
index = 0
}
u.setKeyboardFocus(order[index])
}
func (u *ui) focusOrder() []focusID {
if u.isVaultLocked() {
return []focusID{detailFocusID(detailFieldPassword)}
}
order := []focusID{focusSearch}
if u.state.Section != appstate.SectionRecycleBin {
order = append(order, breadcrumbFocusID(0))
}
if len(u.visible) > 0 {
order = append(order, listFocusID(u.focusedListIndexOrZero()))
}
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
return order
}
func (u *ui) setKeyboardFocus(id focusID) {
u.keyboardFocus = id
if strings.HasPrefix(string(id), "list:") {
u.focusListIndex(focusIndex(id))
}
}
func (u *ui) handleFocusedKey(name key.Name) bool {
switch {
case u.keyboardFocus == focusSearch:
if name == key.NameDownArrow && len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
return true
}
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
return u.handleBreadcrumbKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
return u.handleListKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
return u.handleDetailKey(name)
}
return false
}
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
crumbs := u.breadcrumbLabels()
if len(crumbs) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameLeftArrow:
if index > 0 {
u.keyboardFocus = breadcrumbFocusID(index - 1)
}
return true
case key.NameRightArrow:
if index < len(crumbs)-1 {
u.keyboardFocus = breadcrumbFocusID(index + 1)
}
return true
case key.NameDownArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
case key.NameReturn:
u.activateBreadcrumb(index)
return true
default:
return false
}
}
func (u *ui) handleListKey(name key.Name) bool {
if len(u.visible) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameUpArrow:
if index > 0 {
u.setKeyboardFocus(listFocusID(index - 1))
}
return true
case key.NameDownArrow:
if index < len(u.visible)-1 {
u.setKeyboardFocus(listFocusID(index + 1))
}
return true
case key.NameLeftArrow:
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
return true
case key.NameRightArrow, key.NameReturn:
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
return true
default:
return false
}
}
func (u *ui) handleDetailKey(name key.Name) bool {
fields := detailFocusOrder()
index := u.focusedDetailIndex()
switch name {
case key.NameUpArrow:
if index > 0 {
u.keyboardFocus = detailFocusID(fields[index-1])
}
return true
case key.NameDownArrow:
if index < len(fields)-1 {
u.keyboardFocus = detailFocusID(fields[index+1])
}
return true
case key.NameLeftArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
default:
return false
}
}
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
if !modifiers.Contain(key.ModShortcut) {
return false
}
switch name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
default:
return false
}
return true
}
func (u *ui) activateBreadcrumb(index int) {
var path []string
if index <= 0 {
path = nil
} else {
crumbs := u.breadcrumbLabels()
path = append([]string{}, crumbs[1:index+1]...)
}
u.state.NavigateToPath(path)
u.filter()
if index >= len(u.breadcrumbLabels()) {
index = len(u.breadcrumbLabels()) - 1
}
if index < 0 {
index = 0
}
u.keyboardFocus = breadcrumbFocusID(index)
}
func (u *ui) breadcrumbLabels() []string {
if u.state.Section == appstate.SectionRecycleBin {
return nil
}
labels := append([]string{"Vault"}, u.state.CurrentPath...)
if u.state.Section == appstate.SectionTemplates {
labels = append([]string{"Templates"}, u.state.CurrentPath...)
}
return labels
}
func (u *ui) focusListIndex(index int) {
if len(u.visible) == 0 {
return
}
if index < 0 {
index = 0
}
if index >= len(u.visible) {
index = len(u.visible) - 1
}
u.keyboardFocus = listFocusID(index)
u.state.SelectedEntryID = u.visible[index].ID
u.loadSelectedEntryIntoEditor()
}
func (u *ui) focusedListIndexOrZero() int {
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
index := focusIndex(u.keyboardFocus)
if index >= 0 && index < len(u.visible) {
return index
}
}
for i, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return i
}
}
return 0
}
func (u *ui) focusedDetailFieldOrDefault() detailField {
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
for _, field := range detailFocusOrder() {
if string(field) == name {
return field
}
}
}
return detailFieldTitle
}
func (u *ui) focusedDetailIndex() int {
current := u.focusedDetailFieldOrDefault()
for i, field := range detailFocusOrder() {
if field == current {
return i
}
}
return 0
}
func detailFocusOrder() []detailField {
return editormodel.FocusOrder()
}
func canonicalFocusID(id focusID) focusID {
switch {
case strings.HasPrefix(string(id), "breadcrumb:"):
return breadcrumbFocusID(0)
case strings.HasPrefix(string(id), "list:"):
return listFocusID(0)
case strings.HasPrefix(string(id), "detail:"):
return detailFocusID(detailFieldTitle)
default:
return id
}
}
func focusIndex(id focusID) int {
_, value, ok := strings.Cut(string(id), ":")
if !ok {
return 0
}
index, err := strconv.Atoi(value)
if err != nil {
return 0
}
return index
}
-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"
)
-827
View File
@@ -1,827 +0,0 @@
package appui
import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"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"
)
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}); err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
return err
}
u.vaultPath.SetText(u.saveAsTargetPath())
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) openVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.OpenVault(path, key); err != nil {
return err
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}
func (u *ui) startOpenVaultAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open vault", u.openVaultAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open vault", err)
u.requestMasterPassFocus = true
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
u.requestMasterPassFocus = true
return
}
u.lastLifecycleAction = "open vault"
u.runBackgroundAction("open vault", func() (func() error, error) {
prepared, err := session.PrepareLocalOpen(path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
})
}
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) lifecycleRemoteSyncActionLabel() string {
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Open Vault And Set Up Remote Sync"
}
if hasBoundRecentRemote(u.recentRemotes, path) {
return "Open Vault And Open Remote Sync Settings"
}
return "Open Vault And Set Up Remote Sync"
}
func (u *ui) beginLifecycleRemoteSyncOpen() {
path := strings.TrimSpace(u.vaultPath.Text())
switch {
case path == "":
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
case hasBoundRecentRemote(u.recentRemotes, path):
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
default:
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
}
u.startOpenVaultAction()
}
func (u *ui) applyPendingLifecycleOpenIntent() {
intent := u.pendingLifecycleOpenIntent
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
switch intent {
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
u.openRemoteSyncSetupDialog()
}
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsAction() error {
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.noteRecentVault(path)
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
return err
} else if ok {
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
return err
}
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
strings.TrimSpace(u.remotePath.Text()),
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startOpenRemoteAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open remote vault", u.openRemoteAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
u.requestMasterPassFocus = true
return
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
remotePath := strings.TrimSpace(u.remotePath.Text())
u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) {
binding, bindingOK := u.selectedVaultRemoteBinding()
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
if err != nil {
return nil, err
}
resolved, err := binding.Resolve(preparedLocal.Model)
if err != nil {
return nil, err
}
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}, resolved.Profile.Path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(preparedLocal)
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
manager.ApplyPreparedRemoteOpen(preparedRemote)
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
}
if u.hasOpenVault() {
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
return nil, err
} else if ok {
client = webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
remotePath = resolved.Profile.Path
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
}
}
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedRemoteOpen(prepared)
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.requestMasterPassFocus = true
u.adoptStateCurrentPath()
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startUnlockAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("unlock vault", u.unlockAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
u.requestMasterPassFocus = true
return
}
encoded := append([]byte(nil), manager.EncodedBytes()...)
u.runBackgroundAction("unlock vault", func() (func() error, error) {
prepared, err := session.PrepareUnlock(encoded, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.adoptStateCurrentPath()
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) loadSecuritySettingsFromSession() {
settings, err := u.state.SecuritySettings()
if err != nil {
return
}
u.securityCipher.SetText(settings.Cipher)
u.securityKDF.SetText(settings.KDF)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.syncMenuOpen = false
u.filter()
return nil
}
func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeAdvanced
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
}
func (u *ui) openRemoteSyncSetupDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
u.syncSourceMode = syncSourceRemote
u.syncDirection = syncDirectionPush
u.syncSetupAutomatic.Value = true
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
u.syncSetupAutomatic.Value = false
}
}
func (u *ui) clearSyncLocalImport() {
u.syncLocalImportName = ""
u.syncLocalImportContent = nil
}
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
name := strings.TrimSpace(u.syncLocalImportName)
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
return "", nil, false
}
return name, append([]byte(nil), u.syncLocalImportContent...), true
}
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode {
case syncSourceRemote:
return syncSourceRemote
default:
return syncSourceLocal
}
}
func sanitizeSyncDirection(direction syncDirection) syncDirection {
switch direction {
case syncDirectionPush:
return syncDirectionPush
default:
return syncDirectionPull
}
}
func (u *ui) advancedSyncAction() error {
switch u.syncDirection {
case syncDirectionPush:
return u.advancedSyncToAction()
default:
return u.advancedSyncFromAction()
}
}
func (u *ui) advancedSyncFromAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
client := webdav.Client{
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
return err
}
default:
if name, content, ok := u.selectedSyncLocalImport(); ok {
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
break
}
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeFromLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) startChooseSyncLocalSourceAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose sync path", func() error {
u.clearSyncLocalImport()
return u.chooseExistingFileAction(&u.syncLocalPath)
})
return
}
u.runBackgroundAction("choose sync file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
label := "Selected Android vault"
return func() error {
u.syncLocalImportName = label
u.syncLocalImportContent = append([]byte(nil), content...)
u.syncLocalPath.SetText(label)
return nil
}, nil
})
}
func pickedDocumentName(file io.ReadCloser, fallback string) string {
if named, ok := file.(interface{ Name() string }); ok {
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
return base
}
}
fallback = filepath.Base(strings.TrimSpace(fallback))
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
return "selected-vault.kdbx"
}
return fallback
}
func (u *ui) startChooseVaultPathAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
return
}
u.runBackgroundAction("choose vault file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
name := pickedDocumentName(file, "selected-vault.kdbx")
return func() error {
return u.importSharedVaultBytesAction(name, content)
}, nil
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
client := webdav.Client{
BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
return err
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
return err
}
u.showStatusMessage("Remote sync is set up for this vault.")
}
default:
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeToLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
baseURL = strings.TrimSpace(baseURL)
remotePath = strings.TrimSpace(remotePath)
if baseURL == "" || remotePath == "" {
return fmt.Errorf("remote setup requires base URL and path")
}
input := appstate.RemoteBindingInput{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
CredentialTitle: "WebDAV Sign-In" + func() string {
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
return " · " + user
}
return ""
}(),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.syncSetupMode(),
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
u.remoteBaseURL.SetText(baseURL)
u.remotePath.SetText(remotePath)
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
u.remotePassword.SetText(u.syncRemotePassword.Text())
u.noteRecentRemote(baseURL, remotePath)
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func normalizePendingSharedLookupQuery(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if match := pendingSharedLookupURLPattern.FindString(value); match != "" {
value = match
}
if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
return value
}
func (u *ui) consumePendingSharedLookup() {
path := strings.TrimSpace(u.pendingSharedLookupPath)
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err)
}
return
}
_ = os.Remove(path)
u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data))
u.applyPendingSharedLookup()
}
func (u *ui) applyPendingSharedLookup() {
query := strings.TrimSpace(u.pendingSharedLookupQuery)
status, ok := u.state.Session.(sessionStatus)
if query == "" || !ok || !status.HasVault() || status.IsLocked() {
return
}
u.pendingSharedLookupQuery = ""
u.state.Section = appstate.SectionEntries
u.search.SetText(query)
u.filter()
}
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

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