Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 989b41735f | |||
| a88b8a824b | |||
| eccfb886ee | |||
| 6790399e24 | |||
| 9882d3fc04 | |||
| 59cd01f8e7 | |||
| ea30775eb7 | |||
| 0ce25a9712 | |||
| 32e6fc6c90 | |||
| e8a48fb7aa | |||
| 3b323ea4fd | |||
| 8117e3e8c1 | |||
| 77e92a2368 | |||
| 4b8c1de1a6 | |||
| af2ce66b78 | |||
| a02d4a3b1c | |||
| 57870ca4f1 | |||
| dc7dd19543 | |||
| d522af7d51 | |||
| 2f2338f6f2 | |||
| 12796ef639 | |||
| e16067b345 | |||
| c8f91b300b | |||
| ebb8d4f4ff | |||
| 83bd1334d0 | |||
| 675aeebdeb | |||
| 0de682a3af | |||
| 852c115b2a | |||
| 2ef571c241 | |||
| c017308aa1 | |||
| 885d599db1 | |||
| e757be66d9 | |||
| bc226647e1 | |||
| 533fb2d550 | |||
| 8dfba6e94f | |||
| 6cc86bb944 | |||
| a9c15c2d23 | |||
| b7d6dbdc97 | |||
| 2deca549f5 | |||
| fe3c07e3dd | |||
| c4f110e0ad | |||
| 56a0711860 | |||
| 54f13d352c | |||
| 550d9f362c | |||
| ac3478889c | |||
| 44da1e6599 | |||
| b59cf8044b | |||
| 5838588fc5 | |||
| 0e9fd478e5 | |||
| 2f1cd7876c | |||
| ccaee9fa34 | |||
| c442a20d3e | |||
| cdf0c0c2c7 | |||
| 6ccff23804 | |||
| c3a9c0fddb | |||
| b593b1e6a7 | |||
| fe921b8790 | |||
| 7751b5472a | |||
| 07a071503a | |||
| b256a77d0c | |||
| 74d10535a1 | |||
| 16f603ccba | |||
| 9660369851 | |||
| 0a9201e0d1 | |||
| 74a2bbdc92 | |||
| 168927713c | |||
| 7a50138640 | |||
| d7741d14f5 | |||
| 5a98fe1a75 | |||
| 09e6425b1c | |||
| 4f9792d027 | |||
| 36c6687168 | |||
| 101a875837 | |||
| 81f1bcfca8 | |||
| b33f4905ab | |||
| edf0a9090d | |||
| 9b3f10f086 | |||
| cbfbe3be14 | |||
| f1f5d80ed8 | |||
| edac0f50a6 | |||
| 288cb34f1a | |||
| e88d1fd875 | |||
| a867ac4664 | |||
| 1aab5367a8 | |||
| 5d435f1f1f | |||
| 7868a77c8a | |||
| 43ef58936b | |||
| cb6fbd05a3 | |||
| 739d918c21 | |||
| 332ab58f58 | |||
| 8433d536f6 | |||
| 21b2e60df4 | |||
| 70f18e89bf | |||
| c361ec5ba3 | |||
| 0c6d707325 | |||
| 1c72a5009f | |||
| 9aeb98da58 |
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: keepassgo-ship-it
|
||||
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
||||
---
|
||||
|
||||
# KeePassGO Ship It
|
||||
|
||||
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
||||
|
||||
Use it together with:
|
||||
- `android-emulator-debug` for emulator and `adb` mechanics
|
||||
- `keepass-credentials` for Nextcloud credentials
|
||||
- `public-repo-sanitization` before the commit/push step
|
||||
|
||||
## Meaning Of `ship it`
|
||||
|
||||
When the user says `ship it`, do all of this unless they narrow the scope:
|
||||
|
||||
1. Commit the relevant KeePassGO source changes first.
|
||||
2. Build and install the Arch package from that committed source.
|
||||
3. Build the Android APK from that same committed source.
|
||||
4. Zip the APK.
|
||||
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
||||
6. Install the rebuilt APK in the emulator.
|
||||
7. Launch the rebuilt app in the emulator.
|
||||
8. Open a controlled demo vault in the emulator.
|
||||
|
||||
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
||||
|
||||
## Required Sequence
|
||||
|
||||
### 1. Commit First
|
||||
|
||||
- Make sure the worktree state intended for shipping is committed before building.
|
||||
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
||||
- Before the commit or push, run the public-repo sanitization checks.
|
||||
|
||||
### 2. Build And Install The Arch Package
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
make archlinux-pkgbuild
|
||||
cd packaging/archlinux/keepassgo-git
|
||||
makepkg -si --noconfirm
|
||||
```
|
||||
|
||||
The installed package version must correspond to the committed source, not a dirty worktree.
|
||||
|
||||
### 3. Build The APK
|
||||
|
||||
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
||||
|
||||
```sh
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||
```
|
||||
|
||||
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
||||
|
||||
### 4. Zip The APK
|
||||
|
||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
||||
- Use a name that includes the commit, for example:
|
||||
`keepassgo-<shortsha>-apk.zip`
|
||||
|
||||
### 5. Upload To Nextcloud
|
||||
|
||||
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
||||
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
||||
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
||||
- Ensure that directory exists with mode `700`.
|
||||
- Create secret temp files with mode `600`.
|
||||
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
||||
|
||||
### 6. Emulator Install And Launch
|
||||
|
||||
- Reuse the existing emulator session if one is already running.
|
||||
- Install with replacement:
|
||||
|
||||
```sh
|
||||
adb install -r build/keepassgo.apk
|
||||
```
|
||||
|
||||
- Launch KeePassGO and confirm it is focused.
|
||||
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
||||
|
||||
### 7. Open A Controlled Demo Vault
|
||||
|
||||
- Do not rely on the user's real vault for this step.
|
||||
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
||||
- Open it in the emulator before closing out `ship it`.
|
||||
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
||||
|
||||
## Closeout Requirements
|
||||
|
||||
When reporting back after `ship it`, include:
|
||||
- the commit that was shipped
|
||||
- the installed Arch package version
|
||||
- the APK path
|
||||
- the uploaded ZIP URL
|
||||
- confirmation that the emulator app was launched
|
||||
- confirmation that the controlled demo vault was opened
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep this workflow specific to KeePassGO.
|
||||
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
||||
- Do not use `rm -rf`.
|
||||
- Do not use `rm -f`.
|
||||
@@ -107,6 +107,7 @@ jobs:
|
||||
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 \
|
||||
@@ -126,14 +127,19 @@ jobs:
|
||||
ext=".exe"
|
||||
fi
|
||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" go build -o "${out}" .
|
||||
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
|
||||
make apk
|
||||
signkey_path="$(mktemp)"
|
||||
trap 'rm -f -- "$signkey_path"' EXIT
|
||||
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
||||
export APP_VERSION="$(git describe --tags --always --dirty)"
|
||||
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
|
||||
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
||||
|
||||
- name: Upload CI artifacts
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
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/
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
|
||||
@@ -95,6 +95,20 @@ 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
|
||||
|
||||
@@ -104,9 +118,24 @@ 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:
|
||||
@@ -114,6 +143,14 @@ 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.
|
||||
|
||||
@@ -122,6 +159,13 @@ 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
|
||||
|
||||
@@ -144,6 +188,10 @@ These features are product requirements, not “nice to have” ideas.
|
||||
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 user’s 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 user’s 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.
|
||||
|
||||
@@ -12,6 +12,7 @@ Environment:
|
||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
||||
- `APP_ID` overrides the Android application id.
|
||||
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||
- `APK_OUT` overrides the output path.
|
||||
- `APK_VERSION` overrides the packaged app version.
|
||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||
@@ -28,12 +29,12 @@ Installed machine prerequisites expected by this repo:
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ...
|
||||
go tool gogio -target android ./cmd/keepassgo ...
|
||||
```
|
||||
|
||||
The Android build uses the branded icon asset at:
|
||||
|
||||
- `assets/keepassgo-icon.png`
|
||||
- `internal/assets/keepassgo-icon.png`
|
||||
|
||||
Note:
|
||||
|
||||
|
||||
@@ -5,10 +5,27 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
||||
APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
SIGNPASS ?=
|
||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||
ARCH_REPO_DIR ?= $(CURDIR)
|
||||
|
||||
.PHONY: apk
|
||||
GOGIO_SIGN_FLAGS :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
||||
endif
|
||||
ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate
|
||||
apk: android/keepassgo-android.jar
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||
@@ -24,12 +41,14 @@ apk: android/keepassgo-android.jar
|
||||
go tool gogio -target android \
|
||||
-buildmode exe \
|
||||
-appid $(APP_ID) \
|
||||
-ldflags "$(GO_LDFLAGS)" \
|
||||
$(GOGIO_SIGN_FLAGS) \
|
||||
-o $(APK_OUT) \
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_SDK) \
|
||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||
-icon assets/keepassgo-icon.png \
|
||||
.
|
||||
-icon internal/assets/keepassgo-icon.png \
|
||||
./cmd/keepassgo
|
||||
|
||||
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@@ -42,3 +61,19 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
-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),)
|
||||
|
||||
@@ -38,7 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
||||
Desktop build:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go build ./cmd/keepassgo
|
||||
```
|
||||
|
||||
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
|
||||
@@ -56,6 +63,7 @@ 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
|
||||
|
||||
@@ -82,7 +90,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
||||
Package:
|
||||
|
||||
```bash
|
||||
go tool gogio -target android -icon assets/keepassgo-icon.png .
|
||||
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
||||
```
|
||||
|
||||
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||
@@ -91,3 +99,11 @@ You will need the Android SDK and NDK installed and configured for real device o
|
||||
|
||||
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||
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).
|
||||
|
||||
@@ -11,10 +11,41 @@ The product is not complete until the global exit criteria at the end of this fi
|
||||
These items came from a hands-on emulator and desktop walkthrough.
|
||||
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:
|
||||
@@ -67,6 +98,10 @@ These should remain in the main user flow rather than being hidden behind a sett
|
||||
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
|
||||
- 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:
|
||||
@@ -102,195 +137,6 @@ These are important, but they should likely move behind a dedicated settings gea
|
||||
- Phone and desktop layouts both present a clear information hierarchy.
|
||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||
|
||||
## API Token And gRPC Authorization Parallel Segments
|
||||
|
||||
These segments define the work for programmatic access control over gRPC.
|
||||
They are designed to be independently landable wherever file overlap permits.
|
||||
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
|
||||
|
||||
### API Segment A: Token Domain Model
|
||||
|
||||
Scope:
|
||||
- Represent API tokens as first-class vault-backed records.
|
||||
- Mark token entries explicitly as API credentials rather than generic passwords.
|
||||
- Store token metadata:
|
||||
token id,
|
||||
hashed secret or verifier,
|
||||
display name,
|
||||
client name,
|
||||
created at,
|
||||
expires at,
|
||||
disabled state.
|
||||
- Keep the persisted representation compatible with KDBX entry fields.
|
||||
|
||||
Exit criteria:
|
||||
- A domain type exists for API tokens and round-trips through the persisted vault model.
|
||||
- Generic entry listing can distinguish API token entries from ordinary secrets.
|
||||
- Tests cover create, load, save, and parse behavior for API token entries.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment B: Token Issuance And Rotation
|
||||
|
||||
Scope:
|
||||
- Generate new API tokens for external tools.
|
||||
- Return the cleartext token only at creation or explicit rotation time.
|
||||
- Rotate an existing token while preserving its identity and policy linkage.
|
||||
- Revoke or disable a token without deleting policy history.
|
||||
|
||||
Exit criteria:
|
||||
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
|
||||
- Cleartext token material is only exposed on creation or rotation paths.
|
||||
- Tests cover generation, rotation, and disable/revoke semantics.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment C: Token Expiration
|
||||
|
||||
Scope:
|
||||
- Allow tokens to have optional expiration timestamps.
|
||||
- Treat expired tokens as unauthenticated.
|
||||
- Surface expiration in UI and gRPC management views.
|
||||
- Support non-expiring tokens explicitly.
|
||||
|
||||
Exit criteria:
|
||||
- Expired tokens are rejected by the gRPC authentication path.
|
||||
- Token expiration can be created, edited, and removed through the service layer.
|
||||
- Tests cover valid, expired, and non-expiring token behavior.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment D: Authorization Policy Model
|
||||
|
||||
Scope:
|
||||
- Define an authorization model for token-scoped access.
|
||||
- Support allow and deny rules over:
|
||||
folders/groups,
|
||||
specific entries,
|
||||
entry fields where needed,
|
||||
and operation types.
|
||||
- Keep specific deny rules higher priority than broad allow rules.
|
||||
- Model “not yet decided” separately from “denied”.
|
||||
|
||||
Exit criteria:
|
||||
- A policy evaluator exists for token, resource, and operation tuples.
|
||||
- Explicit deny overrides allow.
|
||||
- Unspecified access is distinguishable from denied access.
|
||||
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment E: gRPC Authentication And Authorization Enforcement
|
||||
|
||||
Scope:
|
||||
- Replace the current single static bearer-token interceptor with token-backed auth.
|
||||
- Authenticate callers using issued KeePassGO API tokens.
|
||||
- Authorize every gRPC method against token policy.
|
||||
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
|
||||
|
||||
Exit criteria:
|
||||
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
|
||||
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
|
||||
- Unauthorized requests return the correct authz/authn gRPC status.
|
||||
- Integration tests cover permitted, denied, expired, and revoked token behavior.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment F: Approval Queue And Pending Access Requests
|
||||
|
||||
Scope:
|
||||
- When a token requests access to a resource that is neither explicitly allowed nor denied:
|
||||
create a pending approval request.
|
||||
- Include:
|
||||
token identity,
|
||||
client name,
|
||||
requested operation,
|
||||
requested group/entry scope,
|
||||
requested time,
|
||||
and permanence choice.
|
||||
- Allow the request to be accepted, denied, or canceled by the user.
|
||||
|
||||
Exit criteria:
|
||||
- Unspecified access creates a pending approval instead of silently denying or allowing.
|
||||
- Pending approvals are queryable from the application layer.
|
||||
- Canceling the prompt results in the API request failing without granting access.
|
||||
- Tests cover pending creation, approval, denial, and cancellation.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment G: Approval UI
|
||||
|
||||
Scope:
|
||||
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
|
||||
- Provide actions:
|
||||
allow once,
|
||||
deny once,
|
||||
allow permanently,
|
||||
deny permanently,
|
||||
cancel.
|
||||
- Make the requested scope and operation clear to the user.
|
||||
- Ensure the dialog appears only for requests not already decided.
|
||||
|
||||
Exit criteria:
|
||||
- A pending request triggers a visible approval surface in the app.
|
||||
- The user can allow, deny, or cancel from the UI.
|
||||
- Permanent decisions become persisted policy rules.
|
||||
- UI tests cover each approval outcome.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment H: gRPC Request Blocking And Resume Behavior
|
||||
|
||||
Scope:
|
||||
- Define how an in-flight gRPC call waits for or fails on user approval.
|
||||
- Hold the request while approval is pending within a bounded timeout.
|
||||
- Return unauthenticated or permission-denied when denied/canceled/expired.
|
||||
- Resume the original call automatically when approval is granted.
|
||||
|
||||
Exit criteria:
|
||||
- Pending requests block safely without leaking goroutines.
|
||||
- Allowed requests resume and complete without the client reissuing the call where practical.
|
||||
- Denied and canceled requests return a consistent gRPC status code and message.
|
||||
- Tests cover timeout, allow, deny, and cancel paths.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment I: Token Management UI
|
||||
|
||||
Scope:
|
||||
- Add UI for listing API tokens.
|
||||
- Create token flow with one-time secret display.
|
||||
- Edit token display metadata and expiration.
|
||||
- Disable, revoke, and rotate tokens.
|
||||
- Show effective policy summary per token.
|
||||
|
||||
Exit criteria:
|
||||
- Users can manage API tokens from the app UI end to end.
|
||||
- One-time token display is explicit and not re-shown later.
|
||||
- Expiration and disable state are visible.
|
||||
- UI tests cover create, rotate, disable, revoke, and edit flows.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment J: Policy Management UI
|
||||
|
||||
Scope:
|
||||
- Let users define folder, entry, and operation scopes for each token.
|
||||
- Show explicit allow and deny rules.
|
||||
- Show inherited implications of a folder-level rule.
|
||||
- Let users review prior permanent decisions created from approval prompts.
|
||||
|
||||
Exit criteria:
|
||||
- Users can inspect and edit token policy from the UI.
|
||||
- Folder-level and entry-level rules are distinguishable and editable.
|
||||
- Permanent prompt decisions are visible as policy.
|
||||
- UI tests cover rule creation, update, and deletion.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### API Segment K: Audit And Event History
|
||||
|
||||
Scope:
|
||||
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
|
||||
- Record authorization failures and expirations without logging secret material.
|
||||
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
|
||||
|
||||
Exit criteria:
|
||||
- Security-relevant API token events are captured without secret leakage.
|
||||
- Approval outcomes and policy changes are auditable.
|
||||
- Tests cover audit generation for the main token lifecycle and approval actions.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 1: Application State Ownership
|
||||
|
||||
Scope:
|
||||
@@ -359,19 +205,6 @@ 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:
|
||||
@@ -577,33 +410,6 @@ 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:
|
||||
@@ -621,11 +427,10 @@ Exit criteria:
|
||||
|
||||
Do not treat the product as complete until all of the following are true:
|
||||
|
||||
- Segment 1 through Segment 20 are all complete.
|
||||
- All remaining numbered segments, API segments, and UI review follow-ups are 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.
|
||||
@@ -635,17 +440,7 @@ 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.
|
||||
|
||||
## Remaining Gaps Against AGENTS.md
|
||||
|
||||
None currently identified.
|
||||
|
||||
The last explicitly tracked gaps are now closed:
|
||||
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
||||
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
||||
|
||||
@@ -22,3 +22,26 @@
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/keepassgo_accessibility_service" />
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||
android:authorities="org.julianfamily.keepassgo.share"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<activity
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/x-keepass2" />
|
||||
<data android:mimeType="application/vnd.keepass" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class SharedVaultImportActivity extends Activity {
|
||||
private static final String TAG = "KeePassGOImport";
|
||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
handleIntent(getIntent());
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleIntent(intent);
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void handleIntent(Intent intent) {
|
||||
Uri uri = resolveSharedUri(intent);
|
||||
if (uri == null) {
|
||||
Log.i(TAG, "no shared vault URI on intent");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
persistPendingImport(uri);
|
||||
Log.i(TAG, "queued shared vault import from " + uri);
|
||||
} catch (IOException | RuntimeException err) {
|
||||
Log.e(TAG, "failed to queue shared vault import", err);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri resolveSharedUri(Intent intent) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_SEND.equals(action)) {
|
||||
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
return intent.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistPendingImport(Uri uri) throws IOException {
|
||||
File dir = new File(getFilesDir(), "keepassgo");
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
throw new IOException("failed to open shared vault stream");
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveDisplayName(Uri uri) {
|
||||
String displayName = queryDisplayName(uri);
|
||||
if (!displayName.isEmpty()) {
|
||||
return displayName;
|
||||
}
|
||||
String lastSegment = uri.getLastPathSegment();
|
||||
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||
return lastSegment.trim();
|
||||
}
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
private String queryDisplayName(Uri uri) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index >= 0) {
|
||||
String value = cursor.getString(index);
|
||||
if (value != null) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException err) {
|
||||
Log.w(TAG, "failed to query display name", err);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void launchMainActivity() {
|
||||
Intent launch = new Intent();
|
||||
launch.setClassName(this, "org.gioui.GioActivity");
|
||||
startActivity(launch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,744 @@
|
||||
const ext = globalThis.browser ?? globalThis.chrome;
|
||||
const nativeHost = "com.keepassgo.browser";
|
||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
const defaultSettings = {
|
||||
bearerToken: ""
|
||||
};
|
||||
const pageStatePrefix = "keepassgo-page-state:";
|
||||
const matchCacheTTL = 30 * 1000;
|
||||
const pendingPollMillis = 1500;
|
||||
const pageStates = new Map();
|
||||
const refreshJobs = new Map();
|
||||
const pendingPollers = new Map();
|
||||
|
||||
function storageGet(keys) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.storage.local.get(keys);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.get(keys, (value) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function storageSet(value) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.storage.local.set(value);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.set(value, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sessionArea() {
|
||||
return ext.storage?.session ?? null;
|
||||
}
|
||||
|
||||
function sessionStorageGet(keys) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.get(keys).then((value) => value || {});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.get(keys, (value) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(value || {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sessionStorageSet(value) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.set(value);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.set(value, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sessionStorageRemove(keys) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.remove(keys);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.remove(keys, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsQuery(query) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.query(query);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.query(query, (tabs) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(tabs);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsGet(tabId) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.get(tabId);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.get(tabId, (tab) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(tab);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsSendMessage(tabId, message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.sendMessage(tabId, message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function connectNative(message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.runtime.sendNativeMessage(nativeHost, message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const stored = await storageGet(["bearerToken"]);
|
||||
return {
|
||||
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function supportsPageStateURL(rawURL) {
|
||||
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
|
||||
}
|
||||
|
||||
function pageStateKey(tabId) {
|
||||
return `${pageStatePrefix}${String(tabId)}`;
|
||||
}
|
||||
|
||||
function cloneTarget(target) {
|
||||
return target && typeof target === "object" ? { ...target } : null;
|
||||
}
|
||||
|
||||
function normalizePageState(state) {
|
||||
return {
|
||||
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
|
||||
configured: Boolean(state?.configured),
|
||||
success: state?.success !== false,
|
||||
status: state?.status ?? null,
|
||||
matches: Array.isArray(state?.matches) ? state.matches : [],
|
||||
error: typeof state?.error === "string" ? state.error : "",
|
||||
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
|
||||
signature: typeof state?.signature === "string" ? state.signature : "",
|
||||
focusTarget: cloneTarget(state?.focusTarget),
|
||||
pendingFill: Boolean(state?.pendingFill),
|
||||
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPageState(tabId, pageUrl) {
|
||||
return normalizePageState({
|
||||
tabId,
|
||||
pageUrl,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: null,
|
||||
matches: [],
|
||||
error: "",
|
||||
pageHasLoginForm: false,
|
||||
signature: "",
|
||||
focusTarget: null,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
lastFilledEntryId: "",
|
||||
updatedAt: 0
|
||||
});
|
||||
}
|
||||
|
||||
async function getPageState(tabId, pageUrl) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return defaultPageState(null, pageUrl || "");
|
||||
}
|
||||
const existing = pageStates.get(tabId);
|
||||
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
|
||||
return normalizePageState(existing);
|
||||
}
|
||||
const stored = await sessionStorageGet(pageStateKey(tabId));
|
||||
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
|
||||
if (pageUrl && state.pageUrl !== pageUrl) {
|
||||
return defaultPageState(tabId, pageUrl);
|
||||
}
|
||||
pageStates.set(tabId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setPageState(tabId, nextState) {
|
||||
const state = normalizePageState({ ...nextState, tabId });
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return state;
|
||||
}
|
||||
pageStates.set(tabId, state);
|
||||
await sessionStorageSet({ [pageStateKey(tabId)]: state });
|
||||
await updateActionState(tabId, state);
|
||||
await notifyContentState(tabId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearPendingPoll(tabId) {
|
||||
const timer = pendingPollers.get(tabId);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
pendingPollers.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPageState(tabId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
pageStates.delete(tabId);
|
||||
refreshJobs.delete(tabId);
|
||||
clearPendingPoll(tabId);
|
||||
await sessionStorageRemove(pageStateKey(tabId));
|
||||
await clearActionState(tabId);
|
||||
}
|
||||
|
||||
function describeError(error) {
|
||||
return error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
}
|
||||
|
||||
function approvalHintForState(state) {
|
||||
if (!state.pendingFill) {
|
||||
return "";
|
||||
}
|
||||
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
}
|
||||
|
||||
function schedulePendingPoll(tabId, pageUrl) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
clearPendingPoll(tabId);
|
||||
const timer = setTimeout(() => {
|
||||
pendingPollers.delete(tabId);
|
||||
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
|
||||
}, pendingPollMillis);
|
||||
pendingPollers.set(tabId, timer);
|
||||
}
|
||||
|
||||
async function notifyContentState(tabId, state) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await tabsSendMessage(tabId, {
|
||||
type: "keepassgo-page-state",
|
||||
state
|
||||
});
|
||||
} catch (_error) {
|
||||
// Ignore pages without a ready content script.
|
||||
}
|
||||
}
|
||||
|
||||
async function clearActionState(tabId) {
|
||||
if (!Number.isInteger(tabId) || !ext.action) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
ext.action.setBadgeText({ tabId, text: "" }),
|
||||
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
|
||||
]);
|
||||
}
|
||||
|
||||
function actionPresentationForState(state) {
|
||||
let badgeText = "";
|
||||
let title = "KeePassGO Browser";
|
||||
let color = "#255f4a";
|
||||
|
||||
if (state.pendingFill) {
|
||||
badgeText = "!";
|
||||
color = "#9f5f0e";
|
||||
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||
} else if (!state.configured) {
|
||||
title = "Configure KeePassGO Browser in extension settings";
|
||||
} else if (!state.success) {
|
||||
badgeText = "!";
|
||||
color = "#9f2f2f";
|
||||
title = state.error || "KeePassGO is unavailable for this page";
|
||||
} else if (state.status?.locked) {
|
||||
title = "Unlock KeePassGO to fill this page";
|
||||
} else if (state.pageHasLoginForm && state.matches.length > 0) {
|
||||
badgeText = String(Math.min(state.matches.length, 9));
|
||||
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
|
||||
} else if (state.pageHasLoginForm) {
|
||||
title = "KeePassGO found no matching entries on this page";
|
||||
}
|
||||
|
||||
return { badgeText, title, color };
|
||||
}
|
||||
|
||||
async function updateActionState(tabId, state) {
|
||||
if (!Number.isInteger(tabId) || !ext.action) {
|
||||
return;
|
||||
}
|
||||
const presentation = actionPresentationForState(state);
|
||||
await Promise.allSettled([
|
||||
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
|
||||
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
|
||||
ext.action.setTitle({ tabId, title: presentation.title })
|
||||
]);
|
||||
}
|
||||
|
||||
async function activePageContext() {
|
||||
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||
return {
|
||||
tabId: Number.isInteger(tab?.id) ? tab.id : null,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
|
||||
async function scanTabForLoginForm(tabId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||
}
|
||||
try {
|
||||
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
|
||||
return {
|
||||
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
|
||||
focusTarget: cloneTarget(response?.focusTarget),
|
||||
signature: typeof response?.signature === "string" ? response.signature : ""
|
||||
};
|
||||
} catch (_error) {
|
||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||
}
|
||||
}
|
||||
|
||||
function shouldReuseMatches(state, force) {
|
||||
if (force || state.pendingFill) {
|
||||
return false;
|
||||
}
|
||||
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
|
||||
}
|
||||
|
||||
function tokenPendingApprovalCount(status) {
|
||||
return Number(status?.tokenPendingApprovalCount || 0);
|
||||
}
|
||||
|
||||
async function fetchStatus(settings) {
|
||||
if (!settings.bearerToken) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
status: null,
|
||||
error: "Set an API token in extension settings."
|
||||
};
|
||||
}
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
return {
|
||||
success: Boolean(status?.success),
|
||||
configured: true,
|
||||
status: status?.status ?? null,
|
||||
error: status?.error ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshPageState(tabId, pageUrl, options = {}) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return defaultPageState(null, pageUrl || "");
|
||||
}
|
||||
const force = Boolean(options.force);
|
||||
const existingJob = refreshJobs.get(tabId);
|
||||
if (existingJob && !force) {
|
||||
return existingJob;
|
||||
}
|
||||
|
||||
const job = (async () => {
|
||||
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
|
||||
if (!supportsPageStateURL(resolvedURL)) {
|
||||
const tab = await tabsGet(tabId).catch(() => null);
|
||||
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
|
||||
}
|
||||
if (!supportsPageStateURL(resolvedURL)) {
|
||||
await clearPageState(tabId);
|
||||
return defaultPageState(tabId, resolvedURL || "");
|
||||
}
|
||||
|
||||
let state = await getPageState(tabId, resolvedURL);
|
||||
if (state.pageUrl !== resolvedURL) {
|
||||
state = defaultPageState(tabId, resolvedURL);
|
||||
}
|
||||
|
||||
const scan = typeof options.pageHasLoginForm === "boolean"
|
||||
? {
|
||||
pageHasLoginForm: options.pageHasLoginForm,
|
||||
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
|
||||
signature: typeof options.signature === "string" ? options.signature : state.signature
|
||||
}
|
||||
: await scanTabForLoginForm(tabId);
|
||||
|
||||
state = {
|
||||
...state,
|
||||
pageUrl: resolvedURL,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
|
||||
signature: typeof scan.signature === "string" ? scan.signature : state.signature
|
||||
};
|
||||
|
||||
const settings = await loadSettings();
|
||||
const statusInfo = await fetchStatus(settings).catch((error) => ({
|
||||
success: false,
|
||||
configured: true,
|
||||
status: null,
|
||||
error: describeError(error)
|
||||
}));
|
||||
|
||||
state = {
|
||||
...state,
|
||||
configured: statusInfo.configured,
|
||||
success: statusInfo.success,
|
||||
status: statusInfo.status,
|
||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
|
||||
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
|
||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||
: "",
|
||||
error: statusInfo.error
|
||||
};
|
||||
|
||||
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
|
||||
state.matches = [];
|
||||
state.updatedAt = Date.now();
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
if (shouldReuseMatches(state, force)) {
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
const matches = await connectNative({
|
||||
action: "find-logins",
|
||||
bearerToken: settings.bearerToken,
|
||||
url: resolvedURL
|
||||
});
|
||||
|
||||
state = {
|
||||
...state,
|
||||
success: Boolean(matches?.success),
|
||||
status: matches?.status ?? state.status,
|
||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
||||
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||
: "",
|
||||
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
||||
error: matches?.error ?? "",
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
})().finally(() => {
|
||||
if (refreshJobs.get(tabId) === job) {
|
||||
refreshJobs.delete(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
refreshJobs.set(tabId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function statusForPage(options = {}) {
|
||||
let page = await activePageContext();
|
||||
if (Number.isInteger(options.tabId)) {
|
||||
const tab = await tabsGet(options.tabId).catch(() => null);
|
||||
page = {
|
||||
tabId: options.tabId,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
if (page.tabId == null) {
|
||||
return defaultPageState(null, page.url);
|
||||
}
|
||||
if (!options.force) {
|
||||
const cached = await getPageState(page.tabId, page.url);
|
||||
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
return refreshPageState(page.tabId, page.url, options);
|
||||
}
|
||||
|
||||
async function fillLogin(tabId, entryId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
throw new Error("No active tab is available.");
|
||||
}
|
||||
const tab = await tabsGet(tabId);
|
||||
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||
if (!supportsPageStateURL(pageUrl)) {
|
||||
throw new Error("This page cannot be filled.");
|
||||
}
|
||||
|
||||
let state = await getPageState(tabId, pageUrl);
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pageUrl,
|
||||
pendingFill: true,
|
||||
pendingEntryId: String(entryId || "").trim(),
|
||||
pendingTarget: cloneTarget(state.focusTarget),
|
||||
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
|
||||
error: "",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
schedulePendingPoll(tabId, pageUrl);
|
||||
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
if (!settings.bearerToken) {
|
||||
throw new Error("API token is not configured.");
|
||||
}
|
||||
|
||||
const response = await connectNative({
|
||||
action: "get-login",
|
||||
bearerToken: settings.bearerToken,
|
||||
entryId,
|
||||
url: pageUrl
|
||||
});
|
||||
if (!response?.success || !response.credential) {
|
||||
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||
}
|
||||
|
||||
const fillResponse = await tabsSendMessage(tabId, {
|
||||
type: "keepassgo-fill-credential",
|
||||
credential: response.credential,
|
||||
target: state.pendingTarget
|
||||
});
|
||||
if (!fillResponse?.ok) {
|
||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||
}
|
||||
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
lastFilledEntryId: String(entryId || "").trim(),
|
||||
error: "",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
clearPendingPoll(tabId);
|
||||
return {
|
||||
credential: response.credential,
|
||||
pageUrl,
|
||||
state
|
||||
};
|
||||
} catch (error) {
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
error: describeError(error),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
clearPendingPoll(tabId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActivePage(options = {}) {
|
||||
const page = await activePageContext();
|
||||
if (page.tabId == null) {
|
||||
return defaultPageState(null, page.url);
|
||||
}
|
||||
return refreshPageState(page.tabId, page.url, options);
|
||||
}
|
||||
|
||||
const backgroundTestExports = {
|
||||
normalizePageState,
|
||||
actionPresentationForState,
|
||||
shouldReuseMatches,
|
||||
tokenPendingApprovalCount,
|
||||
defaultSettings
|
||||
};
|
||||
|
||||
if (isNodeTestEnv) {
|
||||
module.exports = backgroundTestExports;
|
||||
} else {
|
||||
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
switch (message?.type) {
|
||||
case "keepassgo-popup-state":
|
||||
sendResponse(await statusForPage({
|
||||
force: Boolean(message.force),
|
||||
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
|
||||
}));
|
||||
return;
|
||||
case "keepassgo-fill-entry": {
|
||||
const targetTabID = Number.isInteger(message?.tabId)
|
||||
? message.tabId
|
||||
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||
if (Number.isInteger(targetTabID) && message.target) {
|
||||
const targetState = await getPageState(targetTabID, "");
|
||||
await setPageState(targetTabID, {
|
||||
...targetState,
|
||||
focusTarget: cloneTarget(message.target)
|
||||
});
|
||||
}
|
||||
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
||||
return;
|
||||
}
|
||||
case "keepassgo-load-settings":
|
||||
sendResponse({ success: true, settings: await loadSettings() });
|
||||
return;
|
||||
case "keepassgo-save-settings":
|
||||
await storageSet({
|
||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||
});
|
||||
await refreshActivePage({ force: true }).catch(() => null);
|
||||
sendResponse({ success: true });
|
||||
return;
|
||||
case "keepassgo-page-ready":
|
||||
if (Number.isInteger(sender?.tab?.id)) {
|
||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||
force: Boolean(message.force),
|
||||
pageHasLoginForm: Boolean(message.pageHasLoginForm),
|
||||
focusTarget: cloneTarget(message.focusTarget),
|
||||
signature: typeof message.signature === "string" ? message.signature : ""
|
||||
}));
|
||||
return;
|
||||
}
|
||||
sendResponse(defaultPageState(null, ""));
|
||||
return;
|
||||
case "keepassgo-refresh-page-state":
|
||||
if (Number.isInteger(sender?.tab?.id)) {
|
||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
|
||||
return;
|
||||
}
|
||||
sendResponse(defaultPageState(null, ""));
|
||||
return;
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||
}
|
||||
})().catch((error) => {
|
||||
sendResponse({ success: false, error: describeError(error) });
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
||||
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
||||
});
|
||||
|
||||
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
||||
if (typeof changeInfo.url === "string") {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
}
|
||||
if (changeInfo.status === "complete") {
|
||||
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
ext.tabs?.onRemoved?.addListener((tabId) => {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const background = require("./background.js");
|
||||
|
||||
test("normalizePageState preserves focused and pending field targets", () => {
|
||||
const state = background.normalizePageState({
|
||||
tabId: 7,
|
||||
pageUrl: "https://vault.example.invalid/login",
|
||||
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
||||
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
||||
});
|
||||
|
||||
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
||||
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
||||
});
|
||||
|
||||
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
||||
const recentState = {
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
||||
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
||||
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
||||
});
|
||||
|
||||
test("actionPresentationForState prioritizes approval visibility", () => {
|
||||
const presentation = background.actionPresentationForState({
|
||||
pendingFill: true,
|
||||
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
||||
configured: true,
|
||||
success: true,
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }]
|
||||
});
|
||||
|
||||
assert.equal(presentation.badgeText, "!");
|
||||
assert.equal(presentation.color, "#9f5f0e");
|
||||
assert.match(presentation.title, /approve/i);
|
||||
});
|
||||
|
||||
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
||||
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
||||
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||
});
|
||||
|
||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||
assert.equal(background.defaultSettings.bearerToken, "");
|
||||
});
|
||||
@@ -0,0 +1,676 @@
|
||||
const ext = globalThis.browser ?? globalThis.chrome;
|
||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
|
||||
function runtimeSend(message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.runtime.sendMessage(message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.runtime.sendMessage(message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isVisibleInput(input) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return false;
|
||||
}
|
||||
if (input.disabled || input.readOnly) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(input);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
return input.offsetParent !== null || style.position === "fixed";
|
||||
}
|
||||
|
||||
function normalizeRole(rawRole) {
|
||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||
case "password":
|
||||
return "password";
|
||||
default:
|
||||
return "username";
|
||||
}
|
||||
}
|
||||
|
||||
function describeFieldRole(input) {
|
||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||
if (type === "password") {
|
||||
return "password";
|
||||
}
|
||||
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
||||
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
||||
return "username";
|
||||
}
|
||||
return "username";
|
||||
}
|
||||
|
||||
function isUsernameCandidate(input) {
|
||||
if (!isVisibleInput(input)) {
|
||||
return false;
|
||||
}
|
||||
return describeFieldRole(input) === "username";
|
||||
}
|
||||
|
||||
function isPasswordCandidate(input) {
|
||||
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
||||
}
|
||||
|
||||
function dispatchFillEvents(input) {
|
||||
if (typeof InputEvent === "function") {
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
||||
} else {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
const prototype = Object.getPrototypeOf(input);
|
||||
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
|
||||
if (descriptor?.set) {
|
||||
descriptor.set.call(input, value);
|
||||
return;
|
||||
}
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function visibleInputs(scope) {
|
||||
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
|
||||
}
|
||||
|
||||
function resolveFormInputs(anchorInput) {
|
||||
if (anchorInput?.form instanceof HTMLFormElement) {
|
||||
return visibleInputs(anchorInput.form);
|
||||
}
|
||||
return visibleInputs(document);
|
||||
}
|
||||
|
||||
function firstVisiblePassword(scope) {
|
||||
return visibleInputs(scope).find(isPasswordCandidate) || null;
|
||||
}
|
||||
|
||||
function firstVisibleUsername(scope) {
|
||||
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||
}
|
||||
|
||||
function associatedFieldsForAnchor(anchorInput) {
|
||||
const scopeInputs = resolveFormInputs(anchorInput);
|
||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
|
||||
let usernameInput = usernameInScope[0] || null;
|
||||
if (passwordInput && usernameInScope.length !== 0) {
|
||||
const priorSibling = usernameInScope.find((input) =>
|
||||
typeof input.compareDocumentPosition === "function" &&
|
||||
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
);
|
||||
usernameInput = priorSibling || usernameInScope[0] || null;
|
||||
}
|
||||
if (!usernameInput) {
|
||||
usernameInput = firstVisibleUsername(document);
|
||||
}
|
||||
return { usernameInput, passwordInput };
|
||||
}
|
||||
|
||||
function buildFieldDescriptor(input, role) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||
const form = input.form instanceof HTMLFormElement ? input.form : null;
|
||||
const scope = form || document;
|
||||
const inputs = visibleInputs(scope);
|
||||
const fieldIndex = inputs.indexOf(input);
|
||||
const forms = Array.from(document.forms || []);
|
||||
return {
|
||||
role: normalizedRole,
|
||||
formIndex: form ? forms.indexOf(form) : -1,
|
||||
fieldIndex,
|
||||
id: String(input.id || ""),
|
||||
name: String(input.name || ""),
|
||||
autocomplete: String(input.autocomplete || "").toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFieldDescriptor(descriptor) {
|
||||
if (!descriptor || typeof descriptor !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(descriptor.role);
|
||||
const forms = Array.from(document.forms || []);
|
||||
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||
const scope = form || document;
|
||||
const inputs = visibleInputs(scope);
|
||||
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
|
||||
const candidate = inputs[descriptor.fieldIndex];
|
||||
if (describeFieldRole(candidate) === normalizedRole) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (descriptor.id) {
|
||||
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
|
||||
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
|
||||
return byID;
|
||||
}
|
||||
}
|
||||
if (descriptor.name) {
|
||||
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
|
||||
if (byName) {
|
||||
return byName;
|
||||
}
|
||||
}
|
||||
if (normalizedRole === "password") {
|
||||
return firstVisiblePassword(scope) || firstVisiblePassword(document);
|
||||
}
|
||||
return firstVisibleUsername(scope) || firstVisibleUsername(document);
|
||||
}
|
||||
|
||||
function chooseFillTargets(targetDescriptor) {
|
||||
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
|
||||
const associated = associatedFieldsForAnchor(anchorInput);
|
||||
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
|
||||
return {
|
||||
usernameInput: associated.usernameInput,
|
||||
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
|
||||
anchorInput
|
||||
};
|
||||
}
|
||||
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
|
||||
return {
|
||||
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
|
||||
passwordInput: associated.passwordInput,
|
||||
anchorInput
|
||||
};
|
||||
}
|
||||
return {
|
||||
usernameInput: associated.usernameInput,
|
||||
passwordInput: associated.passwordInput,
|
||||
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
|
||||
};
|
||||
}
|
||||
|
||||
function scanLoginFields() {
|
||||
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
|
||||
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
|
||||
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
|
||||
const allVisible = visibleInputs(document);
|
||||
const roles = allVisible
|
||||
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
|
||||
.map((input) => {
|
||||
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
|
||||
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
|
||||
});
|
||||
return {
|
||||
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
|
||||
usernameInput: targets.usernameInput,
|
||||
passwordInput: targets.passwordInput,
|
||||
anchorInput,
|
||||
focusTarget,
|
||||
signature: roles.join("|")
|
||||
};
|
||||
}
|
||||
|
||||
function fillCredential(credential, targetDescriptor) {
|
||||
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
|
||||
|
||||
if (usernameInput && credential.username) {
|
||||
usernameInput.focus();
|
||||
setInputValue(usernameInput, credential.username);
|
||||
dispatchFillEvents(usernameInput);
|
||||
}
|
||||
if (passwordInput && credential.password) {
|
||||
passwordInput.focus();
|
||||
setInputValue(passwordInput, credential.password);
|
||||
dispatchFillEvents(passwordInput);
|
||||
}
|
||||
|
||||
if (!usernameInput && !passwordInput) {
|
||||
return { ok: false, error: "No fillable username or password fields were found." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function domainLabel(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).host || "";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function inlineMatchSummary(match) {
|
||||
const parts = [];
|
||||
if (match.username) {
|
||||
parts.push(match.username);
|
||||
}
|
||||
if (match.url) {
|
||||
const host = domainLabel(match.url);
|
||||
if (host) {
|
||||
parts.push(host);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||
parts.push(match.path.join(" / "));
|
||||
}
|
||||
return parts.join(" · ") || "No username";
|
||||
}
|
||||
|
||||
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
|
||||
if (suppressed || !hasTarget) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
state?.pageHasLoginForm &&
|
||||
(
|
||||
state?.pendingFill ||
|
||||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const contentTestExports = {
|
||||
normalizeRole,
|
||||
describeFieldRole,
|
||||
buildFieldDescriptor,
|
||||
resolveFieldDescriptor,
|
||||
chooseFillTargets,
|
||||
inlineMatchSummary,
|
||||
domainLabel,
|
||||
shouldShowInlineOverlay
|
||||
};
|
||||
|
||||
if (isNodeTestEnv) {
|
||||
module.exports = contentTestExports;
|
||||
} else {
|
||||
let pageState = {
|
||||
configured: true,
|
||||
success: true,
|
||||
matches: [],
|
||||
pageHasLoginForm: false,
|
||||
pendingFill: false,
|
||||
error: "",
|
||||
focusTarget: null
|
||||
};
|
||||
let chooserOpen = false;
|
||||
let inlineSuppressed = false;
|
||||
let refreshTimer = null;
|
||||
let lastReportedSignature = "";
|
||||
let lastReportedTarget = "";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "keepassgo-inline-root";
|
||||
root.setAttribute("aria-live", "polite");
|
||||
const shadow = root.attachShadow({ mode: "open" });
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
all: initial;
|
||||
}
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
display: none;
|
||||
min-width: 220px;
|
||||
max-width: min(340px, calc(100vw - 24px));
|
||||
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
|
||||
color: #214f44;
|
||||
}
|
||||
.dock[data-open="true"] .panel {
|
||||
display: block;
|
||||
}
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #c6d8cf;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffffff, #edf5f0);
|
||||
color: #214f44;
|
||||
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trigger[data-tone="warning"] {
|
||||
border-color: #e4d0ae;
|
||||
background: linear-gradient(180deg, #fff8ed, #f9edd6);
|
||||
color: #7f4b09;
|
||||
}
|
||||
.trigger[data-tone="error"] {
|
||||
border-color: #e4bcbc;
|
||||
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
|
||||
color: #8c2f2f;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.meta {
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.panel {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #d7e3dc;
|
||||
border-radius: 16px;
|
||||
background: #fffdfa;
|
||||
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid #e6efea;
|
||||
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.panel-copy {
|
||||
margin-top: 4px;
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.match-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.match {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-top: 1px solid #eef4f0;
|
||||
background: #fffdfa;
|
||||
color: #214f44;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.match:hover,
|
||||
.match:focus-visible {
|
||||
background: #edf5f0;
|
||||
outline: none;
|
||||
}
|
||||
.match strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e7f1eb;
|
||||
color: #255f4a;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.subtle {
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
padding: 12px 14px;
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
<div class="dock" data-open="false">
|
||||
<button type="button" class="trigger" data-tone="ready">
|
||||
<span class="brand">KeePassGO</span>
|
||||
<span class="meta">Checking this form</span>
|
||||
</button>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">KeePassGO suggestions</div>
|
||||
<div class="panel-copy">Select a matching login for this field.</div>
|
||||
</div>
|
||||
<div class="match-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const dock = shadow.querySelector(".dock");
|
||||
const trigger = shadow.querySelector(".trigger");
|
||||
const meta = shadow.querySelector(".meta");
|
||||
const matchList = shadow.querySelector(".match-list");
|
||||
const panelCopy = shadow.querySelector(".panel-copy");
|
||||
|
||||
function ensureRootMounted() {
|
||||
if (!root.isConnected) {
|
||||
document.documentElement.appendChild(root);
|
||||
}
|
||||
}
|
||||
|
||||
function currentTarget() {
|
||||
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
||||
}
|
||||
|
||||
function hideDock() {
|
||||
chooserOpen = false;
|
||||
dock.style.display = "none";
|
||||
dock.dataset.open = "false";
|
||||
}
|
||||
|
||||
function positionDock() {
|
||||
const anchor = currentTarget();
|
||||
if (!anchor || dock.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(340, Math.max(220, rect.width));
|
||||
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
||||
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
||||
dock.style.left = `${left}px`;
|
||||
dock.style.top = `${top}px`;
|
||||
dock.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
function renderMatches() {
|
||||
matchList.textContent = "";
|
||||
if (pageState.pendingFill) {
|
||||
const pending = document.createElement("div");
|
||||
pending.className = "empty";
|
||||
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
matchList.appendChild(pending);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
||||
matchList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of pageState.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = match.title;
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "subtle";
|
||||
summary.textContent = inlineMatchSummary(match);
|
||||
const quality = document.createElement("span");
|
||||
quality.className = "pill";
|
||||
quality.textContent = match.quality || "Candidate";
|
||||
row.appendChild(title);
|
||||
row.appendChild(summary);
|
||||
row.appendChild(quality);
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
inlineSuppressed = true;
|
||||
hideDock();
|
||||
try {
|
||||
await runtimeSend({
|
||||
type: "keepassgo-fill-entry",
|
||||
entryId: match.id,
|
||||
target: pageState.focusTarget
|
||||
});
|
||||
} catch (_error) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
pendingFill: false,
|
||||
error: "KeePassGO could not fill this page."
|
||||
};
|
||||
renderInlineState();
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
matchList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInlineState() {
|
||||
const target = currentTarget();
|
||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
||||
|
||||
if (!shouldShow) {
|
||||
hideDock();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureRootMounted();
|
||||
dock.style.display = "block";
|
||||
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
|
||||
if (pageState.pendingFill) {
|
||||
meta.textContent = "Approval needed in KeePassGO";
|
||||
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
} else {
|
||||
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||
panelCopy.textContent = "Select a matching login for this field.";
|
||||
}
|
||||
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||
renderMatches();
|
||||
positionDock();
|
||||
}
|
||||
|
||||
function reportFieldState(force) {
|
||||
const scan = scanLoginFields();
|
||||
pageState = {
|
||||
...pageState,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||
return;
|
||||
}
|
||||
lastReportedSignature = scan.signature;
|
||||
lastReportedTarget = nextTarget;
|
||||
void runtimeSend({
|
||||
type: "keepassgo-page-ready",
|
||||
force: Boolean(force),
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
}).then((response) => {
|
||||
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...response,
|
||||
pageHasLoginForm: Boolean(response.pageHasLoginForm),
|
||||
focusTarget: response.focusTarget || pageState.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
function scheduleRefresh(force) {
|
||||
if (refreshTimer !== null) {
|
||||
clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
reportFieldState(force);
|
||||
}, force ? 0 : 120);
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
chooserOpen = !chooserOpen;
|
||||
renderInlineState();
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", () => {
|
||||
scheduleRefresh(false);
|
||||
});
|
||||
|
||||
document.addEventListener("input", () => {
|
||||
scheduleRefresh(false);
|
||||
}, true);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!root.contains(event.target)) {
|
||||
chooserOpen = false;
|
||||
renderInlineState();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
positionDock();
|
||||
}, true);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
positionDock();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (records.some((record) => record.type === "childList")) {
|
||||
scheduleRefresh(false);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === "keepassgo-fill-credential") {
|
||||
try {
|
||||
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-state") {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...(message.state || {})
|
||||
};
|
||||
renderInlineState();
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-scan") {
|
||||
const scan = scanLoginFields();
|
||||
sendResponse({
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
reportFieldState(true);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const content = require("./content.js");
|
||||
|
||||
test("inlineMatchSummary includes username, host, and path context", () => {
|
||||
const summary = content.inlineMatchSummary({
|
||||
username: "dannyocean",
|
||||
url: "https://vault.example.invalid/login",
|
||||
path: ["Root", "Crew"]
|
||||
});
|
||||
|
||||
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
|
||||
});
|
||||
|
||||
test("domainLabel tolerates invalid URLs", () => {
|
||||
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
|
||||
assert.equal(content.domainLabel("not-a-url"), "");
|
||||
});
|
||||
|
||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||
const state = {
|
||||
pageHasLoginForm: true,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: { locked: false },
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false
|
||||
};
|
||||
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"nativeMessaging",
|
||||
"storage",
|
||||
"tabs",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "browser@keepassgo.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePassGO Browser</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="popup">
|
||||
<main class="surface">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>KeePassGO</h1>
|
||||
<p id="page-host" class="subtle">Checking current page</p>
|
||||
</div>
|
||||
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
||||
</header>
|
||||
<section id="status-card" class="status-card">
|
||||
<strong id="status-title">Loading</strong>
|
||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||
</section>
|
||||
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||
<section>
|
||||
<h2>Matches</h2>
|
||||
<div id="matches" class="match-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
|
||||
function runtimeSend(message) {
|
||||
if (usePromiseAPI) {
|
||||
return extPopup.runtime.sendMessage(message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
extPopup.runtime.sendMessage(message, (response) => {
|
||||
const error = extPopup.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hostFromURL(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).host || rawURL;
|
||||
} catch (_error) {
|
||||
return rawURL || "Current page";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(title, message, tone) {
|
||||
const card = document.getElementById("status-card");
|
||||
card.dataset.tone = tone || "neutral";
|
||||
document.getElementById("status-title").textContent = title;
|
||||
document.getElementById("status-message").textContent = message;
|
||||
}
|
||||
|
||||
function matchSubtitle(match) {
|
||||
const parts = [];
|
||||
if (match.username) {
|
||||
parts.push(match.username);
|
||||
}
|
||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||
parts.push(match.path.join(" / "));
|
||||
}
|
||||
return parts.join(" · ") || "No username";
|
||||
}
|
||||
|
||||
function renderMatches(state) {
|
||||
const root = document.getElementById("matches");
|
||||
const targetTabID = popupTabID();
|
||||
root.textContent = "";
|
||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "subtle";
|
||||
empty.textContent = state.pageHasLoginForm
|
||||
? "No matching entries for this page."
|
||||
: "No login fields detected on this page.";
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of state.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match-row";
|
||||
const main = document.createElement("span");
|
||||
main.className = "match-main";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = match.title;
|
||||
const subtitle = document.createElement("span");
|
||||
subtitle.className = "subtle";
|
||||
subtitle.textContent = matchSubtitle(match);
|
||||
const quality = document.createElement("span");
|
||||
quality.className = "quality";
|
||||
quality.textContent = match.quality || "";
|
||||
main.appendChild(title);
|
||||
main.appendChild(subtitle);
|
||||
row.appendChild(main);
|
||||
row.appendChild(quality);
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||
try {
|
||||
const result = await runtimeSend({
|
||||
type: "keepassgo-fill-entry",
|
||||
entryId: match.id,
|
||||
tabId: targetTabID
|
||||
});
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Fill failed.");
|
||||
}
|
||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||
} catch (error) {
|
||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
root.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPageHint(state) {
|
||||
const hint = document.getElementById("page-hint");
|
||||
if (state.pendingFill) {
|
||||
hint.textContent = "Approval is pending in KeePassGO.";
|
||||
return;
|
||||
}
|
||||
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
|
||||
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
|
||||
return;
|
||||
}
|
||||
if (state.pageHasLoginForm) {
|
||||
hint.textContent = "KeePassGO checked this login form already.";
|
||||
return;
|
||||
}
|
||||
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||
}
|
||||
|
||||
function popupTabID() {
|
||||
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||
if (rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const state = await runtimeSend({
|
||||
type: "keepassgo-popup-state",
|
||||
force: true,
|
||||
tabId: popupTabID()
|
||||
});
|
||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||
renderPageHint(state);
|
||||
|
||||
if (!state.configured) {
|
||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||
renderMatches({ matches: [] });
|
||||
return;
|
||||
}
|
||||
if (state.pendingFill) {
|
||||
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
if (!state.success) {
|
||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
if (state.status?.locked) {
|
||||
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||
if (!state.pageHasLoginForm) {
|
||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||
} else if (count === 0) {
|
||||
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||
} else {
|
||||
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
|
||||
}
|
||||
renderMatches(state);
|
||||
} catch (error) {
|
||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||
renderMatches({ matches: [] });
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,178 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #214f44;
|
||||
--ink-soft: #4d6d66;
|
||||
--surface: #fffdfa;
|
||||
--surface-2: #f2f7f3;
|
||||
--line: #d7e3dc;
|
||||
--accent: #255f4a;
|
||||
--accent-soft: #dfeee6;
|
||||
--warn: #9f5f0e;
|
||||
--error: #9f2f2f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
||||
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
||||
}
|
||||
|
||||
body.popup {
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-card[data-tone="ready"] {
|
||||
border-color: #c5dccf;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.status-card[data-tone="warning"] {
|
||||
border-color: #e4d0ae;
|
||||
background: #fbf4e7;
|
||||
}
|
||||
|
||||
.status-card[data-tone="error"] {
|
||||
border-color: #e4bcbc;
|
||||
background: #fcf1f1;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin: -6px 0 16px;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-row,
|
||||
button,
|
||||
.link-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.match-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.match-row:hover,
|
||||
button:hover,
|
||||
.link-button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.match-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quality {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
padding: 10px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
@@ -13,9 +13,10 @@ const (
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -25,6 +26,7 @@ type Config struct {
|
||||
AppID string
|
||||
APKOut string
|
||||
Version string
|
||||
Ldflags string
|
||||
MinSDK string
|
||||
TargetSDK string
|
||||
IconPath string
|
||||
@@ -38,6 +40,7 @@ func DefaultConfig() Config {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: DefaultIconPath,
|
||||
@@ -49,12 +52,13 @@ func (c Config) GogioArgs() []string {
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", c.AppID,
|
||||
"-ldflags", c.Ldflags,
|
||||
"-o", c.APKOut,
|
||||
"-version", c.Version,
|
||||
"-minsdk", c.MinSDK,
|
||||
"-targetsdk", c.TargetSDK,
|
||||
"-icon", c.IconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", DefaultAppID,
|
||||
"-ldflags", DefaultLdflags,
|
||||
"-o", DefaultAPKOut,
|
||||
"-version", DefaultVersion,
|
||||
"-minsdk", DefaultMinSDK,
|
||||
"-targetsdk", DefaultTargetSDK,
|
||||
"-icon", DefaultIconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
|
||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||
@@ -52,6 +53,7 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: filepath.Join(root, "icon.png"),
|
||||
@@ -77,6 +79,7 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
||||
|
||||
func main() {
|
||||
appui.Main()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# Browser Extension
|
||||
|
||||
KeePassGO browser integration uses:
|
||||
|
||||
- the existing local gRPC API in KeePassGO
|
||||
- API tokens for authorization
|
||||
- a tiny native messaging host for browser-to-gRPC transport adaptation
|
||||
|
||||
The browser extension does **not** talk to vault files directly.
|
||||
|
||||
## Security Model
|
||||
|
||||
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||
- The browser extension stores the API token in browser extension storage.
|
||||
- The native messaging host receives the token on each request from the extension.
|
||||
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
||||
- The native messaging host does not persist the token to disk.
|
||||
|
||||
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
|
||||
|
||||
## RPCs Used
|
||||
|
||||
The browser integration uses:
|
||||
|
||||
- `GetSessionStatus`
|
||||
- `FindBrowserLogins`
|
||||
- `GetBrowserCredential`
|
||||
|
||||
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
|
||||
|
||||
## Default Listener
|
||||
|
||||
On desktop KeePassGO listens on a Unix socket by default:
|
||||
|
||||
- primary location: under the user runtime directory
|
||||
- fallback: `/run/user/<uid>` if present
|
||||
- final fallback: a private directory under the system temp directory
|
||||
|
||||
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
|
||||
|
||||
```bash
|
||||
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
|
||||
```
|
||||
|
||||
## Native Host
|
||||
|
||||
Build the bridge:
|
||||
|
||||
```bash
|
||||
go build ./cmd/keepassgo-browser-bridge
|
||||
```
|
||||
|
||||
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
|
||||
|
||||
Install a Firefox native messaging manifest:
|
||||
|
||||
```bash
|
||||
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
|
||||
```
|
||||
|
||||
Install a Chromium native messaging manifest:
|
||||
|
||||
```bash
|
||||
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
|
||||
```
|
||||
|
||||
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
|
||||
|
||||
For a fixed Chromium ID:
|
||||
|
||||
1. Keep a stable Chromium extension signing key outside the repo.
|
||||
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
|
||||
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
|
||||
|
||||
## Extension Setup
|
||||
|
||||
Firefox:
|
||||
|
||||
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||
2. Open the extension settings page.
|
||||
3. Paste an API token scoped for browser login lookup and credential copy.
|
||||
|
||||
Chromium / Chrome:
|
||||
|
||||
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
||||
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
||||
3. Configure the API token in the extension settings page.
|
||||
|
||||
## Current Browser Flow
|
||||
|
||||
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
|
||||
- The toolbar badge shows when KeePassGO found matches for the current page.
|
||||
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
|
||||
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
|
||||
|
||||
For extension-side regression checks, run:
|
||||
|
||||
```bash
|
||||
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||
```
|
||||
|
||||
For a reproducible real-browser Chromium validation harness, run:
|
||||
|
||||
```bash
|
||||
make browser-extension-validate
|
||||
```
|
||||
|
||||
That target:
|
||||
|
||||
- validates the Firefox flow by default with a temporary addon install
|
||||
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
|
||||
- builds the native messaging bridge
|
||||
- starts a stub KeePassGO gRPC server and a local login page
|
||||
- drives the browser through inline match discovery, approval visibility, and fill completion
|
||||
|
||||
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
|
||||
|
||||
## Required Token Scope
|
||||
|
||||
At minimum, the browser token should have policy rules allowing:
|
||||
|
||||
- `list_entries` for the groups you want the browser to search
|
||||
- `copy_username` for entries the browser may fill
|
||||
- `copy_password` for entries the browser may fill
|
||||
- `copy_url` for entries the browser may confirm against page URL
|
||||
@@ -0,0 +1,148 @@
|
||||
# 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 user’s 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.
|
||||
@@ -2,8 +2,6 @@ module git.julianfamily.org/keepassgo
|
||||
|
||||
go 1.26
|
||||
|
||||
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
gioui.org/x v0.8.0
|
||||
|
||||
@@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
|
||||
@@ -4,29 +4,33 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"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"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type DirtyProvider func() bool
|
||||
|
||||
type Host struct {
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
socketPath string
|
||||
}
|
||||
|
||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||
@@ -35,13 +39,17 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
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(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
server := grpc.NewServer()
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
host := &Host{
|
||||
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
||||
listener: listener,
|
||||
lifecycle: lifecycle,
|
||||
dirty: dirty,
|
||||
listenAddr: listener.Addr().String(),
|
||||
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
||||
socketPath: socketPath,
|
||||
started: true,
|
||||
}
|
||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||
@@ -91,7 +100,16 @@ func (h *Host) Stop() error {
|
||||
}
|
||||
h.started = false
|
||||
h.grpcServer.Stop()
|
||||
return h.listener.Close()
|
||||
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 {
|
||||
@@ -120,3 +138,35 @@ func (h *Host) SyncFromLifecycle() error {
|
||||
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
|
||||
}
|
||||
@@ -2,13 +2,17 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"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"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -19,7 +23,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
lifecycle := &session.Manager{}
|
||||
if err := lifecycle.Create(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t),
|
||||
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 {
|
||||
@@ -32,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
}
|
||||
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("tcp", host.Address())
|
||||
return net.Dial(network, endpoint)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -70,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,21 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
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"
|
||||
@@ -99,6 +101,581 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||
}
|
||||
if resp.GetLocked() {
|
||||
t.Fatal("GetSessionStatus().Locked = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceSessionStatusIncludesPendingApprovalsForCurrentToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, secret, err := apitokens.Issue("Browser Token", "browser-extension", nil, time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
token.SecretHash = hashSecretForTest(secret)
|
||||
otherToken, otherSecret, err := apitokens.Issue("Other Token", "automation-client", nil, time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() other error = %v", err)
|
||||
}
|
||||
otherToken.SecretHash = hashSecretForTest(otherSecret)
|
||||
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
token.Entry([]string{"Root", "API Tokens"}),
|
||||
otherToken.Entry([]string{"Root", "API Tokens"}),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
ctx, cancel := context.WithCancel(tokenContext(secret))
|
||||
defer cancel()
|
||||
waiting := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.approvals.Request(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{
|
||||
Kind: apitokens.ResourceEntry,
|
||||
EntryID: "vault-console",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
waiting <- err
|
||||
}()
|
||||
|
||||
otherCtx, otherCancel := context.WithCancel(tokenContext(otherSecret))
|
||||
defer otherCancel()
|
||||
otherWaiting := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.approvals.Request(otherCtx, otherToken, apitokens.OperationListEntries, apitokens.Resource{
|
||||
Kind: apitokens.ResourceGroup,
|
||||
Path: []string{"Root", "Shared"},
|
||||
})
|
||||
otherWaiting <- err
|
||||
}()
|
||||
|
||||
waitForServerPendingApproval(t, service, 2)
|
||||
|
||||
resp, err := client.GetSessionStatus(tokenContext(secret), &keepassgov1.GetSessionStatusRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||
}
|
||||
if got := resp.GetPendingApprovalCount(); got != 2 {
|
||||
t.Fatalf("GetSessionStatus().PendingApprovalCount = %d, want 2", got)
|
||||
}
|
||||
if got := resp.GetTokenPendingApprovalCount(); got != 1 {
|
||||
t.Fatalf("GetSessionStatus().TokenPendingApprovalCount = %d, want 1", got)
|
||||
}
|
||||
|
||||
for _, pending := range waitForServerPendingApproval(t, service, 2) {
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
|
||||
t.Fatalf("ResolveApproval(%q) error = %v", pending.ID, err)
|
||||
}
|
||||
}
|
||||
if err := <-waiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
|
||||
t.Fatalf("Request(token) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
|
||||
}
|
||||
if err := <-otherWaiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
|
||||
t.Fatalf("Request(otherToken) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
|
||||
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
|
||||
})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 1 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||
}
|
||||
if resp.Matches[0].Id != "vault-console" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
|
||||
}
|
||||
if resp.Matches[0].Quality != "exact-host" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "gitlab",
|
||||
Title: "GitLab",
|
||||
Username: "jjulian",
|
||||
Password: "secret",
|
||||
URL: "gitlab.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://gitlab.com/users/sign_in",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 1 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||
}
|
||||
if resp.Matches[0].Id != "gitlab" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want gitlab", resp.Matches[0].Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "codex-nextcloud",
|
||||
Title: "Nextcloud (codex)",
|
||||
Username: "jjulian",
|
||||
Password: "secret-1",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
{
|
||||
ID: "joe-nextcloud",
|
||||
Title: "Nextcloud",
|
||||
Username: "jjulian",
|
||||
Password: "secret-2",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://nextcloud.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 1 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||
}
|
||||
if resp.Matches[0].Id != "codex-nextcloud" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "rusty-casino",
|
||||
Title: "Rusty Casino",
|
||||
Username: "rustyryan",
|
||||
Password: "bellagio-1",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio"},
|
||||
},
|
||||
{
|
||||
ID: "benedict-vault",
|
||||
Title: "Benedict Vault",
|
||||
Username: "terrybenedict",
|
||||
Password: "bellagio-2",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio", "Denied"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
|
||||
),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.heist.example.invalid/login",
|
||||
})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
|
||||
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
resp := <-respCh
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 1 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||
}
|
||||
if got := resp.Matches[0].Id; got != "rusty-casino" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "codex-nextcloud",
|
||||
Title: "Nextcloud (codex)",
|
||||
Username: "jjulian",
|
||||
Password: "secret-1",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Joe", "codex"},
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||
Path: []string{"Joe", "codex"},
|
||||
})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
|
||||
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
resp := <-respCh
|
||||
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "inside-man-accounts",
|
||||
Title: "Inside Man Accounts",
|
||||
Username: "daltonrussell",
|
||||
Password: "diamond-1",
|
||||
URL: "https://accounts.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bank"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 0 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
|
||||
}
|
||||
|
||||
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "inside-man-accounts",
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "codex-nextcloud",
|
||||
Title: "Nextcloud (codex)",
|
||||
Username: "jjulian",
|
||||
Password: "secret-1",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Joe", "codex"},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||
Path: []string{"Joe", "codex"},
|
||||
})
|
||||
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 got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "codex-nextcloud",
|
||||
Title: "Nextcloud (codex)",
|
||||
Username: "jjulian",
|
||||
Password: "secret-1",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Joe", "codex"},
|
||||
{"Recycle Bin"},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||
Path: []string{"Joe", "codex"},
|
||||
})
|
||||
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 got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Shared"},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
|
||||
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Shared"},
|
||||
{"Recycle Bin"},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) {
|
||||
t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||
}
|
||||
if resp.Id != "vault-console" {
|
||||
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
|
||||
}
|
||||
if resp.Password != "token-1" {
|
||||
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -802,6 +1379,103 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
}
|
||||
lifecycle := &stubLifecycle{model: model}
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
clipboardWriter := &memoryClipboardWriter{}
|
||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||
server := grpc.NewServer()
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() {
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return listener.DialContext(ctx)
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
client := keepassgov1.NewVaultServiceClient(conn)
|
||||
|
||||
_, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
|
||||
Entry: &keepassgov1.Entry{
|
||||
Id: "lifecycle-visible",
|
||||
Title: "Lifecycle Visible",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
current, err := lifecycle.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
if _, err := current.EntryByID("lifecycle-visible"); err != nil {
|
||||
t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Joe", "codex"},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
upserted, err := client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
|
||||
Entry: &keepassgov1.Entry{
|
||||
Id: "codex-created",
|
||||
Title: "Codex Created",
|
||||
Path: []string{"Joe", "codex"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
if got := upserted.Entry.Path; !slices.Equal(got, []string{"Joe", "codex"}) {
|
||||
t.Fatalf("UpsertEntry().Entry.Path = %v, want [Joe codex]", got)
|
||||
}
|
||||
|
||||
listed, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||
Path: []string{"Joe", "codex"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Id != "codex-created" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want created codex entry", listed.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1053,64 +1727,67 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry
|
||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
t.Helper()
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
}
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return newTestClientForModel(t, model)
|
||||
}
|
||||
|
||||
@@ -1125,7 +1802,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
clipboardWriter := &memoryClipboardWriter{}
|
||||
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
server := grpc.NewServer()
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
go func() {
|
||||
@@ -1168,7 +1845,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
|
||||
))
|
||||
lifecycle.model = model
|
||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||
server := grpc.NewServer()
|
||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||
|
||||
go func() {
|
||||
@@ -9,33 +9,34 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"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")
|
||||
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"
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ type Broker struct {
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
nextID func() string
|
||||
notify func()
|
||||
}
|
||||
|
||||
type pendingRequest struct {
|
||||
@@ -108,9 +110,18 @@ func (b *Broker) Pending() []Request {
|
||||
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{}, ErrRequestTimedOut
|
||||
return Result{}, ErrBrokerNotConfigured
|
||||
}
|
||||
|
||||
pending := &pendingRequest{
|
||||
@@ -128,12 +139,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
|
||||
|
||||
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)
|
||||
@@ -3,10 +3,11 @@ package apiapproval
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||
@@ -120,6 +121,46 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var broker *Broker
|
||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||
if !errors.Is(err, ErrBrokerNotConfigured) {
|
||||
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
broker := NewBroker(time.Minute)
|
||||
changes := make(chan int, 4)
|
||||
broker.SetChangeNotifier(func() {
|
||||
changes <- len(broker.Pending())
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
waitForPending(t, broker, 1)
|
||||
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("Request() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
got := []int{<-changes, <-changes}
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []int{0, 1}) {
|
||||
t.Fatalf("change notifications = %v, want [0 1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
@@ -16,6 +16,12 @@ const (
|
||||
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"
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,15 +55,18 @@ const (
|
||||
DecisionDeny Decision = "deny"
|
||||
DecisionPrompt Decision = "prompt"
|
||||
|
||||
OperationListEntries Operation = "list_entries"
|
||||
OperationListGroups Operation = "list_groups"
|
||||
OperationReadEntry Operation = "read_entry"
|
||||
OperationCopyPassword Operation = "copy_password"
|
||||
OperationCopyUsername Operation = "copy_username"
|
||||
OperationCopyURL Operation = "copy_url"
|
||||
OperationMutateEntry Operation = "mutate_entry"
|
||||
OperationMutateGroup Operation = "mutate_group"
|
||||
OperationManageVault Operation = "manage_vault"
|
||||
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 {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
@@ -26,8 +28,12 @@ const (
|
||||
SectionRecycleBin Section = "recycle-bin"
|
||||
SectionAPITokens Section = "api-tokens"
|
||||
SectionAPIAudit Section = "api-audit"
|
||||
SectionAbout Section = "about"
|
||||
)
|
||||
|
||||
const entriesRootLabel = "Root"
|
||||
const templatesRootLabel = "Templates"
|
||||
|
||||
type CurrentSession interface {
|
||||
Current() (vault.Model, error)
|
||||
}
|
||||
@@ -53,6 +59,15 @@ type SaveableSession interface {
|
||||
Save() error
|
||||
}
|
||||
|
||||
type AutoSaveableSession interface {
|
||||
SaveableSession
|
||||
HasSaveTarget() bool
|
||||
}
|
||||
|
||||
type RemoteAwareSession interface {
|
||||
IsRemote() bool
|
||||
}
|
||||
|
||||
type SynchronizableSession interface {
|
||||
CurrentSession
|
||||
Synchronize() error
|
||||
@@ -87,6 +102,10 @@ type RemoteOpenableSession interface {
|
||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type WarningSession interface {
|
||||
ConsumeWarning() string
|
||||
}
|
||||
|
||||
type SecurityConfigurableSession interface {
|
||||
ConfigureSecurity(vault.SecuritySettings) error
|
||||
SecuritySettings() vault.SecuritySettings
|
||||
@@ -100,6 +119,8 @@ type ApprovalManager interface {
|
||||
type State struct {
|
||||
Session CurrentSession
|
||||
Approvals ApprovalManager
|
||||
AuditLog *apiaudit.Log
|
||||
AutoSaveRemote bool
|
||||
Section Section
|
||||
CurrentPath []string
|
||||
SearchQuery string
|
||||
@@ -132,116 +153,176 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||
return apitokens.Entries(model)
|
||||
}
|
||||
|
||||
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := slices.Clone(model.RemoteProfiles)
|
||||
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := slices.Clone(model.Entries)
|
||||
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) DisableAPIToken(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) DeleteAPIToken(id string) error {
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
if err := apitokens.Delete(model, id); err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type tokenMutationResult struct {
|
||||
token apitokens.Token
|
||||
secret string
|
||||
}
|
||||
|
||||
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
if err := apitokens.Delete(&model, id); err != nil {
|
||||
return err
|
||||
result, err := mutate(&model)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
s.recordTokenAudit(eventType, result.token, message)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||
if s.AuditLog == nil {
|
||||
return
|
||||
}
|
||||
s.AuditLog.Record(apiaudit.Event{
|
||||
Type: eventType,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Resource: apitokens.Resource{
|
||||
Kind: apitokens.ResourceEntry,
|
||||
Path: apitokens.EntryPath,
|
||||
EntryID: token.ID,
|
||||
},
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||
@@ -260,8 +341,7 @@ func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
if err := security.ConfigureSecurity(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) ShowSection(section Section) {
|
||||
@@ -303,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||
return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
@@ -323,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
|
||||
}
|
||||
|
||||
if s.Section != SectionEntries {
|
||||
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
||||
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
||||
if s.Section == SectionTemplates {
|
||||
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
|
||||
}
|
||||
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
||||
}
|
||||
|
||||
return model.ChildGroups(s.CurrentPath), nil
|
||||
return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
|
||||
}
|
||||
|
||||
func (s *State) SelectVisibleIndex(index int) error {
|
||||
@@ -373,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
|
||||
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||
switch s.Section {
|
||||
case SectionTemplates:
|
||||
return slices.Clone(model.Templates)
|
||||
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
|
||||
case SectionRecycleBin:
|
||||
return slices.Clone(model.RecycleBin)
|
||||
case SectionAPITokens, SectionAPIAudit:
|
||||
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
|
||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||
return nil
|
||||
default:
|
||||
return slices.Clone(model.Entries)
|
||||
return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
|
||||
path := slices.Clone(entry.Path)
|
||||
switch s.Section {
|
||||
case SectionTemplates:
|
||||
if len(path) == 0 || path[0] != "Templates" {
|
||||
path = append([]string{"Templates"}, path...)
|
||||
}
|
||||
path = logicalTemplatePath(path)
|
||||
case SectionRecycleBin:
|
||||
path = append([]string{"Recycle Bin"}, path...)
|
||||
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
|
||||
case SectionEntries:
|
||||
path = logicalEntriesPath(path)
|
||||
}
|
||||
return strings.Join(path, " / ")
|
||||
}
|
||||
@@ -448,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
|
||||
return out
|
||||
}
|
||||
|
||||
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{entriesRootLabel}
|
||||
}
|
||||
if path[0] == entriesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
|
||||
path = path[1:]
|
||||
}
|
||||
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func logicalEntriesPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{entriesRootLabel}
|
||||
}
|
||||
if path[0] == entriesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
if path[0] == vaultview.KeepassRoot {
|
||||
path = path[1:]
|
||||
}
|
||||
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func logicalTemplatePath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{templatesRootLabel}
|
||||
}
|
||||
if path[0] == templatesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func templatesViewPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] == templatesRootLabel {
|
||||
return append([]string(nil), path[1:]...)
|
||||
}
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
|
||||
func entriesViewPathForModel(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
|
||||
return append([]string(nil), path[1:]...)
|
||||
case usesLogicalEntriesRoot(model):
|
||||
return append([]string(nil), path...)
|
||||
case path[0] == entriesRootLabel:
|
||||
return append([]string(nil), path[1:]...)
|
||||
default:
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
}
|
||||
|
||||
func logicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry.Path = logicalEntriesPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = logicalEntry(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func logicalEntries(entries []vault.Entry) []vault.Entry {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]vault.Entry, len(entries))
|
||||
for i := range entries {
|
||||
out[i] = logicalEntry(entries[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
|
||||
entry.Path = logicalTemplatePath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = logicalTemplateEntry(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]vault.Entry, len(entries))
|
||||
for i := range entries {
|
||||
out[i] = logicalTemplateEntry(entries[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
|
||||
entry.Path = entriesViewPathForModel(model, entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = entryForModel(model, entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func templateEntryForModel(entry vault.Entry) vault.Entry {
|
||||
entry.Path = templatesViewPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = templateEntryForModel(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func usesPhysicalEntriesRoot(model vault.Model) bool {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func usesLogicalEntriesRoot(model vault.Model) bool {
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func childGroups(entries []vault.Entry, path []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var groups []string
|
||||
@@ -472,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
|
||||
return groups
|
||||
}
|
||||
|
||||
func sectionGroupView(model vault.Model, section Section) vaultview.View {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return vaultview.VaultTemplates(model)
|
||||
default:
|
||||
return vaultview.VaultRoot(model)
|
||||
}
|
||||
}
|
||||
|
||||
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return templatesViewPath(path)
|
||||
default:
|
||||
return entriesViewPathForModel(model, path)
|
||||
}
|
||||
}
|
||||
|
||||
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return logicalTemplatePath(path)
|
||||
default:
|
||||
return logicalEntriesPathForModel(model, path)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) DeleteSelectedEntry() error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -489,8 +753,7 @@ func (s *State) DeleteSelectedEntry() error {
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) RestoreEntry(id string) error {
|
||||
@@ -509,8 +772,7 @@ func (s *State) RestoreEntry(id string) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
@@ -524,11 +786,10 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertEntry(entry)
|
||||
model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
@@ -542,11 +803,10 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertTemplate(entry)
|
||||
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
||||
@@ -560,15 +820,17 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
entry, err := model.InstantiateTemplate(templateID, overrides)
|
||||
entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
s.Dirty = true
|
||||
return entry, nil
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
return logicalEntry(entry), nil
|
||||
}
|
||||
|
||||
func (s *State) DeleteTemplate(id string) error {
|
||||
@@ -590,8 +852,7 @@ func (s *State) DeleteTemplate(id string) error {
|
||||
if s.SelectedEntryID == id {
|
||||
s.SelectedEntryID = ""
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
||||
@@ -612,7 +873,9 @@ func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error)
|
||||
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = duplicate.ID
|
||||
s.Dirty = true
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
return duplicate, nil
|
||||
}
|
||||
|
||||
@@ -632,8 +895,7 @@ func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) Lock() error {
|
||||
@@ -656,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
|
||||
return fmt.Errorf("session is not lockable")
|
||||
}
|
||||
|
||||
return session.Unlock(key)
|
||||
if err := session.Unlock(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||
@@ -669,8 +937,7 @@ func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) EnterGroup(name string) {
|
||||
@@ -697,6 +964,25 @@ func (s *State) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) markDirtyAndAutoSave() error {
|
||||
s.Dirty = true
|
||||
session, ok := s.Session.(SaveableSession)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if autosave, ok := s.Session.(AutoSaveableSession); ok && !autosave.HasSaveTarget() {
|
||||
return nil
|
||||
}
|
||||
if remote, ok := s.Session.(RemoteAwareSession); ok && remote.IsRemote() && !s.AutoSaveRemote {
|
||||
return nil
|
||||
}
|
||||
if err := session.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Synchronize() error {
|
||||
session, ok := s.Session.(SynchronizableSession)
|
||||
if !ok {
|
||||
@@ -800,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -830,9 +1119,73 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||
}
|
||||
|
||||
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, input)
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -844,10 +1197,10 @@ func (s *State) CreateGroup(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.CreateGroup(s.CurrentPath, name)
|
||||
view := sectionGroupView(model, s.Section)
|
||||
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
@@ -859,16 +1212,18 @@ func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := append([]string(nil), s.CurrentPath...)
|
||||
if err := model.MoveGroup(current, parent); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
|
||||
currentViewPath := sectionGroupViewPath(model, s.Section, current)
|
||||
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
|
||||
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
if len(current) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
||||
if len(currentViewPath) > 0 {
|
||||
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) RenameCurrentGroup(newName string) error {
|
||||
@@ -882,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -890,8 +1246,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
||||
if len(s.CurrentPath) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
||||
}
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) MoveSelectedEntry(path []string) error {
|
||||
@@ -905,13 +1260,12 @@ func (s *State) MoveSelectedEntry(path []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
|
||||
if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) DeleteCurrentGroup() error {
|
||||
@@ -925,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.DeleteGroup(s.CurrentPath); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -934,8 +1289,7 @@ func (s *State) DeleteCurrentGroup() error {
|
||||
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
|
||||
}
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||
@@ -961,8 +1315,7 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
|
||||
}
|
||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
@@ -988,8 +1341,7 @@ func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) er
|
||||
}
|
||||
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
@@ -1018,8 +1370,7 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
||||
model.Entries[i].Attachments = nil
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
|
||||
return vault.ErrEntryNotFound
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
@@ -22,11 +23,11 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Root", "Crew", "Internet"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -54,7 +55,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
||||
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableStubSession{model: vault.Model{}}
|
||||
state := State{Session: session}
|
||||
auditLog := apiaudit.New(10)
|
||||
state := State{Session: session, AuditLog: auditLog}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(24 * time.Hour)
|
||||
|
||||
@@ -162,6 +164,176 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
if len(tokens) != 0 {
|
||||
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
||||
}
|
||||
|
||||
events := auditLog.Events()
|
||||
if len(events) != 5 {
|
||||
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
|
||||
}
|
||||
if events[0].Type != apiaudit.EventTokenDeleted ||
|
||||
events[1].Type != apiaudit.EventTokenRevoked ||
|
||||
events[2].Type != apiaudit.EventTokenDisabled ||
|
||||
events[3].Type != apiaudit.EventTokenRotated ||
|
||||
events[4].Type != apiaudit.EventTokenIssued {
|
||||
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
|
||||
}
|
||||
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
|
||||
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
|
||||
}
|
||||
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
|
||||
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAPITokenAutoSavesWhenSessionSupportsSaving(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableSaveableStubSession{model: vault.Model{}, hasSaveTarget: true}
|
||||
state := State{Session: session}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
|
||||
t.Fatalf("IssueAPIToken() error = %v", err)
|
||||
}
|
||||
|
||||
if session.saveCalls != 1 {
|
||||
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after autosave")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAPITokenDoesNotAutoSaveWithoutSaveTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableSaveableStubSession{model: vault.Model{}}
|
||||
state := State{Session: session}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
|
||||
t.Fatalf("IssueAPIToken() error = %v", err)
|
||||
}
|
||||
|
||||
if session.saveCalls != 0 {
|
||||
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true when no save target exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAPITokenDoesNotAutoSaveForRemoteSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableSaveableStubSession{
|
||||
model: vault.Model{},
|
||||
hasSaveTarget: true,
|
||||
remote: true,
|
||||
}
|
||||
state := State{Session: session}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
|
||||
t.Fatalf("IssueAPIToken() error = %v", err)
|
||||
}
|
||||
|
||||
if session.saveCalls != 0 {
|
||||
t.Fatalf("saveCalls = %d, want 0", session.saveCalls)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true for remote session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAPITokenAutoSavesForRemoteSessionWhenEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableSaveableStubSession{
|
||||
model: vault.Model{},
|
||||
hasSaveTarget: true,
|
||||
remote: true,
|
||||
}
|
||||
state := State{
|
||||
Session: session,
|
||||
AutoSaveRemote: true,
|
||||
}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, _, err := state.IssueAPIToken("CLI", "grpc-cli", nil, now); err != nil {
|
||||
t.Fatalf("IssueAPIToken() error = %v", err)
|
||||
}
|
||||
|
||||
if session.saveCalls != 1 {
|
||||
t.Fatalf("saveCalls = %d, want 1", session.saveCalls)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after remote autosave")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
{
|
||||
ID: "archive-webdav",
|
||||
Name: "Archive Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/archive.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteProfiles() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
|
||||
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteCredentialEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteCredentialEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
|
||||
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
@@ -173,7 +345,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -187,7 +359,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +372,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -350,7 +522,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -411,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Crew"},
|
||||
{"keepass", "Crew", "Internet"},
|
||||
{"keepass", "Crew", "Security Office"},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "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{"Bellagio", "Vault Console"}) {
|
||||
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
|
||||
}
|
||||
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
|
||||
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Crew"},
|
||||
{"keepass", "Crew", "Internet"},
|
||||
{"keepass", "Crew", "Security Office"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.ChildGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Crew"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -419,7 +660,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
},
|
||||
@@ -432,8 +673,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +844,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
@@ -619,7 +860,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||
}
|
||||
|
||||
@@ -964,6 +1205,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
|
||||
}
|
||||
|
||||
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if len(state.CurrentPath) != 0 {
|
||||
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||
}
|
||||
if state.SelectedEntryID != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after bound remote open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "missing-profile",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||
}
|
||||
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
|
||||
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||
}
|
||||
credentials, err := sess.model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{Session: stubSession{model: vault.Model{}}}
|
||||
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1149,10 +1569,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
SelectedEntryID: "vault-console",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||
@@ -1185,8 +1605,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
||||
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)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,14 +1628,68 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
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 !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupAutoSavesWhenSessionSupportsSaving(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableSaveableStubSession{model: testVaultModel(), hasSaveTarget: true}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root"},
|
||||
}
|
||||
|
||||
if err := state.CreateGroup("Finance"); err != nil {
|
||||
t.Fatalf("CreateGroup() error = %v", err)
|
||||
}
|
||||
|
||||
if sess.saveCalls != 1 {
|
||||
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after autosave")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupSaveFailureLeavesStateDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableSaveableStubSession{
|
||||
model: testVaultModel(),
|
||||
hasSaveTarget: true,
|
||||
saveErr: errors.New("save failed"),
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root"},
|
||||
}
|
||||
|
||||
err := state.CreateGroup("Finance")
|
||||
if err == nil || err.Error() != "save failed" {
|
||||
t.Fatalf("CreateGroup() error = %v, want save failed", err)
|
||||
}
|
||||
if sess.saveCalls != 1 {
|
||||
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after failed autosave")
|
||||
}
|
||||
|
||||
got, childErr := state.ChildGroups()
|
||||
if childErr != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", childErr)
|
||||
}
|
||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1229,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
||||
t.Fatalf("CreateGroup() error = %v", err)
|
||||
}
|
||||
|
||||
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
||||
if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
|
||||
}
|
||||
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
||||
if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1282,8 +1756,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||
@@ -1300,11 +1774,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
SelectedEntryID: "bellagio",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
@@ -1512,7 +1986,7 @@ func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1552,16 +2026,51 @@ func (s *saveableStubSession) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mutableSaveableStubSession struct {
|
||||
model vault.Model
|
||||
err error
|
||||
saveCalls int
|
||||
saveErr error
|
||||
hasSaveTarget bool
|
||||
remote bool
|
||||
}
|
||||
|
||||
func (s *mutableSaveableStubSession) Current() (vault.Model, error) {
|
||||
if s.err != nil {
|
||||
return vault.Model{}, s.err
|
||||
}
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *mutableSaveableStubSession) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
|
||||
func (s *mutableSaveableStubSession) Save() error {
|
||||
s.saveCalls++
|
||||
return s.saveErr
|
||||
}
|
||||
|
||||
func (s *mutableSaveableStubSession) HasSaveTarget() bool {
|
||||
return s.hasSaveTarget
|
||||
}
|
||||
|
||||
func (s *mutableSaveableStubSession) IsRemote() bool {
|
||||
return s.remote
|
||||
}
|
||||
|
||||
type lifecycleStubSession struct {
|
||||
createCalls int
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
createCalls int
|
||||
model vault.Model
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remoteClient webdav.Client
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||
return vault.Model{}, nil
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||
@@ -1579,7 +2088,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
||||
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||
s.remoteClient = client
|
||||
s.remotePath = path
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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), " / ")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -10,91 +10,12 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||
)
|
||||
|
||||
func apiOperations() []apitokens.Operation {
|
||||
return []apitokens.Operation{
|
||||
apitokens.OperationListEntries,
|
||||
apitokens.OperationListGroups,
|
||||
apitokens.OperationReadEntry,
|
||||
apitokens.OperationCopyPassword,
|
||||
apitokens.OperationCopyUsername,
|
||||
apitokens.OperationCopyURL,
|
||||
apitokens.OperationMutateEntry,
|
||||
apitokens.OperationMutateGroup,
|
||||
apitokens.OperationManageVault,
|
||||
}
|
||||
}
|
||||
|
||||
type apiAuditQuickFilter struct {
|
||||
Label string
|
||||
Query string
|
||||
}
|
||||
|
||||
func apiAuditDecisionLabel(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 apiAuditOperationLabel(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 apiAuditEventSearchTerms(event apiaudit.Event) string {
|
||||
parts := []string{
|
||||
string(event.Type),
|
||||
apiAuditDecisionLabel(event.Type),
|
||||
event.TokenName,
|
||||
event.ClientName,
|
||||
string(event.Operation),
|
||||
apiAuditOperationLabel(event.Operation),
|
||||
strings.Join(event.Resource.Path, " / "),
|
||||
event.Resource.EntryID,
|
||||
event.Message,
|
||||
}
|
||||
switch event.Type {
|
||||
case apiaudit.EventApprovalAllowed:
|
||||
parts = append(parts, "allow approved")
|
||||
case apiaudit.EventApprovalDenied:
|
||||
parts = append(parts, "deny denied")
|
||||
case apiaudit.EventApprovalRequested:
|
||||
parts = append(parts, "prompt requested")
|
||||
case apiaudit.EventApprovalCanceled:
|
||||
parts = append(parts, "cancel canceled")
|
||||
case apiaudit.EventApprovalTimedOut:
|
||||
parts = append(parts, "timeout timed out")
|
||||
case apiaudit.EventAuthRejected:
|
||||
parts = append(parts, "rejected unauthorized")
|
||||
}
|
||||
return strings.ToLower(strings.Join(parts, " "))
|
||||
}
|
||||
type apiAuditQuickFilter = apiui.AuditQuickFilter
|
||||
|
||||
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
||||
if len(filters) == 0 {
|
||||
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
||||
}
|
||||
if _, ok := decisionSeen[event.Type]; !ok {
|
||||
decisionSeen[event.Type] = struct{}{}
|
||||
label := apiAuditDecisionLabel(event.Type)
|
||||
label := apiui.AuditDecisionLabel(event.Type)
|
||||
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
||||
}
|
||||
if strings.TrimSpace(string(event.Operation)) == "" {
|
||||
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
||||
continue
|
||||
}
|
||||
operationSeen[event.Operation] = struct{}{}
|
||||
label := apiAuditOperationLabel(event.Operation)
|
||||
label := apiui.AuditOperationLabel(event.Operation)
|
||||
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
|
||||
}
|
||||
|
||||
@@ -208,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
|
||||
return clicks
|
||||
}
|
||||
|
||||
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
|
||||
if count <= 0 {
|
||||
u.apiPolicyEdits = nil
|
||||
return nil
|
||||
}
|
||||
if len(u.apiPolicyEdits) == count {
|
||||
return u.apiPolicyEdits
|
||||
}
|
||||
clicks := make([]widget.Clickable, count)
|
||||
copy(clicks, u.apiPolicyEdits)
|
||||
u.apiPolicyEdits = clicks
|
||||
return clicks
|
||||
}
|
||||
|
||||
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||
u.selectedAPIPolicyIndex = -1
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
u.apiTokenSecret = ""
|
||||
@@ -222,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||
u.apiPolicyAllow.Value = true
|
||||
u.apiPolicyGroupScope = true
|
||||
u.apiPolicyGroupScopeW.Value = true
|
||||
u.ensureAPIPolicyEditClickables(0)
|
||||
u.ensureAPIPolicyRemoveClickables(0)
|
||||
return
|
||||
}
|
||||
@@ -233,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||
u.apiTokenExpiresAt.SetText("")
|
||||
}
|
||||
u.apiTokenDisabled.Value = token.Disabled
|
||||
u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||
}
|
||||
|
||||
@@ -321,7 +259,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) {
|
||||
|
||||
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
||||
value := apitokens.Operation(strings.TrimSpace(text))
|
||||
for _, operation := range apiOperations() {
|
||||
for _, operation := range apiui.Operations() {
|
||||
if operation == value {
|
||||
return value, nil
|
||||
}
|
||||
@@ -329,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
||||
return "", fmt.Errorf("unknown API operation %q", text)
|
||||
}
|
||||
|
||||
func (u *ui) addAPIPolicyRuleAction() error {
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
return fmt.Errorf("no API token selected")
|
||||
}
|
||||
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
|
||||
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
||||
if err != nil {
|
||||
return err
|
||||
return apitokens.PolicyRule{}, err
|
||||
}
|
||||
rule := apitokens.PolicyRule{
|
||||
Operation: operation,
|
||||
@@ -349,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
||||
if u.apiPolicyGroupScope {
|
||||
path := parsePath(u.apiPolicyPath.Text())
|
||||
if len(path) == 0 {
|
||||
return fmt.Errorf("policy path is required for group scope")
|
||||
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
|
||||
}
|
||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
||||
} else {
|
||||
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
||||
if entryID == "" {
|
||||
return fmt.Errorf("entry id is required for entry scope")
|
||||
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
|
||||
}
|
||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (u *ui) addAPIPolicyRuleAction() error {
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
return fmt.Errorf("no API token selected")
|
||||
}
|
||||
rule, err := u.apiPolicyRuleFromEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !uiHasPolicyRule(token.Policies, rule) {
|
||||
token.Policies = append(token.Policies, rule)
|
||||
}
|
||||
@@ -369,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) editAPIPolicyRuleAction(index int) error {
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
return fmt.Errorf("no API token selected")
|
||||
}
|
||||
if index < 0 || index >= len(token.Policies) {
|
||||
return fmt.Errorf("policy index %d out of range", index)
|
||||
}
|
||||
rule := token.Policies[index]
|
||||
u.selectedAPIPolicyIndex = index
|
||||
u.apiPolicyOperation.SetText(string(rule.Operation))
|
||||
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
|
||||
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||
u.apiPolicyGroupScope = false
|
||||
u.apiPolicyGroupScopeW.Value = false
|
||||
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
|
||||
u.apiPolicyPath.SetText("")
|
||||
return nil
|
||||
}
|
||||
u.apiPolicyGroupScope = true
|
||||
u.apiPolicyGroupScopeW.Value = true
|
||||
u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
|
||||
u.apiPolicyEntryID.SetText("")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAPIPolicyRuleAction() error {
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
return fmt.Errorf("no API token selected")
|
||||
}
|
||||
index := u.selectedAPIPolicyIndex
|
||||
if index < 0 || index >= len(token.Policies) {
|
||||
return fmt.Errorf("no API policy rule selected")
|
||||
}
|
||||
rule, err := u.apiPolicyRuleFromEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, existing := range token.Policies {
|
||||
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
|
||||
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
|
||||
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||
return err
|
||||
}
|
||||
u.loadSelectedAPITokenIntoEditor()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
token.Policies[index] = rule
|
||||
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||
return err
|
||||
}
|
||||
u.loadSelectedAPITokenIntoEditor()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) apiPolicyGroupPathSummary() string {
|
||||
path := parsePath(u.apiPolicyPath.Text())
|
||||
if len(path) == 0 {
|
||||
@@ -436,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) cancelAPIPolicyEditAction() error {
|
||||
u.loadSelectedAPITokenIntoEditor()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||
if u.auditLog == nil {
|
||||
return nil
|
||||
@@ -450,7 +458,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||
}
|
||||
filtered := make([]apiaudit.Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
haystack := apiAuditEventSearchTerms(event)
|
||||
haystack := apiui.AuditEventSearchTerms(event)
|
||||
if strings.Contains(haystack, query) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
@@ -468,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
|
||||
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||
resource = "Entry: " + rule.Resource.EntryID
|
||||
} else if len(rule.Resource.Path) > 0 {
|
||||
resource = strings.Join(rule.Resource.Path, " / ")
|
||||
resource = apiui.FormatResourcePath(rule.Resource.Path)
|
||||
}
|
||||
return effect, operation, resource
|
||||
}
|
||||
@@ -785,7 +793,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
||||
click := &buttons[i]
|
||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
|
||||
@@ -799,7 +807,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
||||
click := &buttons[i]
|
||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
|
||||
@@ -828,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
|
||||
|
||||
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
token, ok := u.selectedAPIToken()
|
||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
||||
var editClicks []widget.Clickable
|
||||
var removeClicks []widget.Clickable
|
||||
if ok {
|
||||
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||
}
|
||||
rows := []layout.Widget{
|
||||
@@ -997,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
||||
}),
|
||||
@@ -1030,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
rows = append(rows,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
actionLabel := "Add Rule"
|
||||
title := "Policy Composer"
|
||||
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
|
||||
if 0 <= u.selectedAPIPolicyIndex {
|
||||
actionLabel = "Save Rule"
|
||||
title = "Policy Editor"
|
||||
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
|
||||
lbl := material.Label(u.theme, unit.Sp(14), title)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
|
||||
lbl := material.Label(u.theme, unit.Sp(12), description)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -1051,7 +1073,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.apiPolicyGroupScopeW.Value {
|
||||
@@ -1093,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if 0 <= u.selectedAPIPolicyIndex {
|
||||
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.selectedAPIPolicyIndex < 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1174,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
|
||||
if len(resource.Path) == 0 {
|
||||
return "/"
|
||||
}
|
||||
return strings.Join(resource.Path, " / ")
|
||||
return apiui.FormatResourcePath(resource.Path)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"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) {
|
||||
@@ -275,8 +275,7 @@ func (u *ui) deleteCurrentGroupAction() error {
|
||||
return err
|
||||
}
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.adoptStateCurrentPath()
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
"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 string
|
||||
type detailField = editormodel.Field
|
||||
|
||||
const (
|
||||
focusSearch focusID = "search"
|
||||
|
||||
detailFieldID detailField = "id"
|
||||
detailFieldTitle detailField = "title"
|
||||
detailFieldUsername detailField = "username"
|
||||
detailFieldPassword detailField = "password"
|
||||
detailFieldURL detailField = "url"
|
||||
detailFieldPath detailField = "path"
|
||||
detailFieldTags detailField = "tags"
|
||||
detailFieldPasswordProfile detailField = "password-profile"
|
||||
detailFieldNotes detailField = "notes"
|
||||
detailFieldFields detailField = "fields"
|
||||
detailFieldHistoryIndex detailField = "history-index"
|
||||
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 {
|
||||
@@ -41,6 +55,68 @@ 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
|
||||
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
|
||||
}
|
||||
|
||||
func detailFocusOrder() []detailField {
|
||||
return []detailField{
|
||||
detailFieldID,
|
||||
detailFieldTitle,
|
||||
detailFieldUsername,
|
||||
detailFieldPassword,
|
||||
detailFieldURL,
|
||||
detailFieldPath,
|
||||
detailFieldTags,
|
||||
detailFieldPasswordProfile,
|
||||
detailFieldNotes,
|
||||
detailFieldFields,
|
||||
detailFieldHistoryIndex,
|
||||
}
|
||||
return editormodel.FocusOrder()
|
||||
}
|
||||
|
||||
func canonicalFocusID(id focusID) focusID {
|
||||
@@ -0,0 +1,9 @@
|
||||
package lifecycle
|
||||
|
||||
type OpenIntent string
|
||||
|
||||
const (
|
||||
OpenIntentNone OpenIntent = ""
|
||||
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
||||
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
||||
)
|
||||
@@ -0,0 +1,778 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func (u *ui) createVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.CreateVault(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.lifecycleMode == "local" {
|
||||
u.selectedVaultRemoteProfileID = ""
|
||||
u.selectedVaultRemoteCredentialEntryID = ""
|
||||
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||
u.remoteBaseURL.SetText("")
|
||||
u.remotePath.SetText("")
|
||||
u.remoteUsername.SetText("")
|
||||
u.remotePassword.SetText("")
|
||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(u.saveAsTargetPath())
|
||||
u.noteRecentVault(u.saveAsTargetPath())
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.adoptStateCurrentPath()
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.OpenVault(path, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.adoptStateCurrentPath()
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenVaultAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open vault", u.openVaultAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
u.lastLifecycleAction = "open vault"
|
||||
u.runBackgroundAction("open vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareLocalOpen(path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(prepared)
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.adoptStateCurrentPath()
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
if hasBoundRecentRemote(u.recentRemotes, path) {
|
||||
return "Open Vault And Open Remote Sync Settings"
|
||||
}
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
|
||||
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
switch {
|
||||
case path == "":
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
case hasBoundRecentRemote(u.recentRemotes, path):
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
||||
default:
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
||||
}
|
||||
u.startOpenVaultAction()
|
||||
}
|
||||
|
||||
func (u *ui) applyPendingLifecycleOpenIntent() {
|
||||
intent := u.pendingLifecycleOpenIntent
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
switch intent {
|
||||
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
||||
u.openRemoteSyncSetupDialog()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) saveAction() error {
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsAction() error {
|
||||
path := u.saveAsTargetPath()
|
||||
if err := u.state.SaveAs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(path)
|
||||
u.noteRecentVault(path)
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
strings.TrimSpace(u.remotePath.Text()),
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenRemoteAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open remote vault", u.openRemoteAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
remotePath := strings.TrimSpace(u.remotePath.Text())
|
||||
u.lastLifecycleAction = "open remote vault"
|
||||
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
||||
binding, bindingOK := u.selectedVaultRemoteBinding()
|
||||
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
||||
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := binding.Resolve(preparedLocal.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}, resolved.Profile.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(preparedLocal)
|
||||
u.vaultPath.SetText(binding.LocalVaultPath)
|
||||
u.noteRecentVault(binding.LocalVaultPath)
|
||||
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
||||
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
if u.hasOpenVault() {
|
||||
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
client = webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
remotePath = resolved.Profile.Path
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
}
|
||||
}
|
||||
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedRemoteOpen(prepared)
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
remotePath,
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lockAction() error {
|
||||
u.clearMasterPassword()
|
||||
if err := u.state.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.requestMasterPassFocus = true
|
||||
u.adoptStateCurrentPath()
|
||||
u.resetPasswordPeek()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) unlockAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Unlock(key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.adoptStateCurrentPath()
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startUnlockAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
||||
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareUnlock(encoded, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedUnlock(prepared)
|
||||
u.resetPasswordPeek()
|
||||
u.adoptStateCurrentPath()
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) changeMasterKeyAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.state.ChangeMasterKey(key)
|
||||
}
|
||||
|
||||
func (u *ui) loadSecuritySettingsFromSession() {
|
||||
settings, err := u.state.SecuritySettings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.securityCipher.SetText(settings.Cipher)
|
||||
u.securityKDF.SetText(settings.KDF)
|
||||
}
|
||||
|
||||
func (u *ui) clearMasterPassword() {
|
||||
u.masterPassword.SetText("")
|
||||
}
|
||||
|
||||
func (u *ui) synchronizeAction() error {
|
||||
if err := u.state.Synchronize(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.syncMenuOpen = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openAdvancedSyncDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||
u.syncSourceMode = u.syncDefaultSourceMode
|
||||
u.syncDirection = u.syncDefaultDirection
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteSyncSetupDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||
u.syncSourceMode = syncSourceRemote
|
||||
u.syncDirection = syncDirectionPush
|
||||
u.syncSetupAutomatic.Value = true
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
||||
u.syncSetupAutomatic.Value = false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) clearSyncLocalImport() {
|
||||
u.syncLocalImportName = ""
|
||||
u.syncLocalImportContent = nil
|
||||
}
|
||||
|
||||
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||
name := strings.TrimSpace(u.syncLocalImportName)
|
||||
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||
}
|
||||
|
||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||
switch mode {
|
||||
case syncSourceRemote:
|
||||
return syncSourceRemote
|
||||
default:
|
||||
return syncSourceLocal
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
||||
switch direction {
|
||||
case syncDirectionPush:
|
||||
return syncDirectionPush
|
||||
default:
|
||||
return syncDirectionPull
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncAction() error {
|
||||
switch u.syncDirection {
|
||||
case syncDirectionPush:
|
||||
return u.advancedSyncToAction()
|
||||
default:
|
||||
return u.advancedSyncFromAction()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncFromAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose sync path", func() error {
|
||||
u.clearSyncLocalImport()
|
||||
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||
})
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label := "Selected Android vault"
|
||||
return func() error {
|
||||
u.syncLocalImportName = label
|
||||
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||
u.syncLocalPath.SetText(label)
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
||||
if named, ok := file.(interface{ Name() string }); ok {
|
||||
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
fallback = filepath.Base(strings.TrimSpace(fallback))
|
||||
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
||||
return "selected-vault.kdbx"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (u *ui) startChooseVaultPathAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := pickedDocumentName(file, "selected-vault.kdbx")
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction(name, content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) startImportSharedVaultAction() {
|
||||
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncToAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
||||
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
||||
client := webdav.Client{
|
||||
BaseURL: baseURL,
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
u.showStatusMessage("Remote sync is set up for this vault.")
|
||||
}
|
||||
default:
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
remotePath = strings.TrimSpace(remotePath)
|
||||
if baseURL == "" || remotePath == "" {
|
||||
return fmt.Errorf("remote setup requires base URL and path")
|
||||
}
|
||||
input := appstate.RemoteBindingInput{
|
||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
||||
BaseURL: baseURL,
|
||||
RemotePath: remotePath,
|
||||
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
CredentialTitle: "WebDAV Sign-In" + func() string {
|
||||
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
||||
return " · " + user
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
CredentialPath: append([]string(nil), u.currentPath...),
|
||||
SyncMode: u.syncSetupMode(),
|
||||
}
|
||||
binding, err := u.state.ConfigureRemoteBinding(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||
u.remoteBaseURL.SetText(baseURL)
|
||||
u.remotePath.SetText(remotePath)
|
||||
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
||||
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
||||
u.noteRecentRemote(baseURL, remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsTargetPath() string {
|
||||
path := strings.TrimSpace(u.saveAsPath.Text())
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
return u.defaultSaveAsPath
|
||||
}
|
||||
|
||||
func (u *ui) importedVaultDestination(name string) string {
|
||||
baseTarget := u.saveAsTargetPath()
|
||||
baseDir := filepath.Dir(baseTarget)
|
||||
baseName := filepath.Base(strings.TrimSpace(name))
|
||||
switch {
|
||||
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||
return baseTarget
|
||||
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||
return filepath.Join(baseDir, baseName)
|
||||
default:
|
||||
return baseTarget
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) consumePendingSharedVaultImport() {
|
||||
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
name := "shared-vault.kdbx"
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
if rawName, err := os.ReadFile(namePath); err == nil {
|
||||
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
||||
name = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
_ = os.Remove(namePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||
target := u.importedVaultDestination(name)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
u.lifecycleMode = "local"
|
||||
u.vaultPath.SetText(target)
|
||||
u.noteRecentVault(target)
|
||||
u.state.ErrorMessage = ""
|
||||
u.state.StatusMessage = ""
|
||||
u.requestMasterPassFocus = true
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) currentShareableVaultPath() string {
|
||||
return strings.TrimSpace(u.vaultPath.Text())
|
||||
}
|
||||
|
||||
func (u *ui) shareCurrentVaultAction() error {
|
||||
if u.vaultSharer == nil {
|
||||
return fmt.Errorf("vault sharing is not available on this platform")
|
||||
}
|
||||
path := u.currentShareableVaultPath()
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,13 +15,29 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
)
|
||||
|
||||
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||
panel := card
|
||||
if u.usesCompactViewport() {
|
||||
panel = compactCard
|
||||
}
|
||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
rows := []layout.Widget{
|
||||
u.lifecycleBranding,
|
||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||
u.lifecycleControls,
|
||||
}
|
||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
return rows[i](gtx)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
busy := u.lifecycleBusy()
|
||||
showLocalChooser := u.showLocalVaultChooser()
|
||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -31,206 +47,14 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
||||
if u.lifecycleMode == "remote" {
|
||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
||||
}
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote")
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleMode == "remote" {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if showRemoteChooser && !busy {
|
||||
return u.recentRemoteList(gtx)
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
||||
box.Color = accentColor
|
||||
return box.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.recentVaultList(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
switch {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}),
|
||||
)
|
||||
return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -249,168 +73,232 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.recentVaultList(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.lifecycleAdvancedDisclosure(gtx)
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions {
|
||||
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
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 tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
switch {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
if u.shouldPrioritizeLifecyclePrimaryActions() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
if busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(u.lifecycleAdvancedCard),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, "Opening Vault...")
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || u.lifecycleAdvancedHidden {
|
||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleMode == "remote" {
|
||||
label := u.remoteOpenButtonLabel()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, label)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.selectedRemoteConnectionCard(gtx)
|
||||
}),
|
||||
)
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
label = "Opening Vault..."
|
||||
}
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, label)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||
func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
|
||||
return u.usesCompactViewport()
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardHeading() string {
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
return "CACHED VAULT"
|
||||
}
|
||||
return "SELECTED CONNECTION"
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardPrimaryText() string {
|
||||
record := u.currentRemoteRecord()
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if label := friendlyRecentVaultLabel(path); label != "" {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return friendlyRecentRemoteLabel(record)
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardDetailLines() []string {
|
||||
record := u.currentRemoteRecord()
|
||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(lastGroup) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
lines := make([]string, 0, 3)
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
||||
lines = append(lines, dir)
|
||||
}
|
||||
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
||||
} else {
|
||||
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
||||
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
||||
}
|
||||
if len(lastGroup) > 0 {
|
||||
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
||||
@@ -446,6 +334,11 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
||||
@@ -455,6 +348,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string {
|
||||
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
|
||||
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
|
||||
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
|
||||
return summary + " · Syncs automatically on open and save."
|
||||
}
|
||||
return summary + " · Sync manually when you choose Use Remote Sync."
|
||||
}
|
||||
return "Open this vault to set up a WebDAV sync target for it."
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
||||
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
||||
}
|
||||
@@ -570,76 +474,6 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.recentRemotes) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
||||
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
maxY := gtx.Dp(unit.Dp(180))
|
||||
if gtx.Constraints.Max.Y > maxY {
|
||||
gtx.Constraints.Max.Y = maxY
|
||||
}
|
||||
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
|
||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
||||
}
|
||||
return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
record := u.recentRemotes[i]
|
||||
label := friendlyRecentRemoteLabel(record)
|
||||
selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.recentRemoteClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(record.LastGroup) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / "))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions {
|
||||
if !selected {
|
||||
return compactCard(gtx, w)
|
||||
@@ -750,21 +584,6 @@ func normalizedRemoteHost(baseURL string) string {
|
||||
return strings.TrimSuffix(host, "/")
|
||||
}
|
||||
|
||||
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
|
||||
username := strings.TrimSpace(record.Username)
|
||||
hasPassword := record.Password != ""
|
||||
switch {
|
||||
case username != "" && hasPassword:
|
||||
return "saved username and password"
|
||||
case username != "":
|
||||
return "saved username"
|
||||
case hasPassword:
|
||||
return "saved password"
|
||||
default:
|
||||
return "location only"
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||
items := u.selectedAttachmentItems()
|
||||
if len(items) == 0 {
|
||||
@@ -1050,7 +869,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Group Tools"
|
||||
size := unit.Sp(12)
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
size = unit.Sp(11)
|
||||
}
|
||||
lbl := material.Label(u.theme, size, label)
|
||||
@@ -1060,7 +879,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
})
|
||||
}
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return content(gtx)
|
||||
}
|
||||
return compactCard(gtx, content)
|
||||
@@ -1125,7 +944,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
||||
@@ -1184,7 +1003,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
|
||||
@@ -1252,13 +1071,17 @@ func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Ed
|
||||
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
func localVaultPathHelpForRuntime(goos string) string {
|
||||
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) {
|
||||
return "Choose the existing .kdbx file to open."
|
||||
}
|
||||
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
return localVaultPathHelpForRuntime(runtime.GOOS)
|
||||
}
|
||||
|
||||
func keyFileHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return "Optional path to a KeePass-compatible key file."
|
||||
@@ -1267,7 +1090,7 @@ func keyFileHelp() string {
|
||||
}
|
||||
|
||||
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) {
|
||||
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
|
||||
}
|
||||
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
|
||||
@@ -0,0 +1,12 @@
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package list
|
||||
|
||||
type EntriesSectionState struct {
|
||||
Path []string
|
||||
SearchQuery string
|
||||
SelectedEntryID string
|
||||
Editing bool
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -8,23 +8,27 @@ import (
|
||||
gioclipboard "gioui.org/io/clipboard"
|
||||
"gioui.org/layout"
|
||||
|
||||
appclipboard "git.julianfamily.org/keepassgo/clipboard"
|
||||
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 newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
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) {
|
||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||
if !ok {
|
||||
return
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package platform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := newPlatformClipboardWriter("android", nil)
|
||||
writer := NewClipboardWriter("android", nil)
|
||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/api"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func Main() {
|
||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
||||
flag.Parse()
|
||||
|
||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
||||
|
||||
width := unit.Dp(1180)
|
||||
height := unit.Dp(760)
|
||||
if strings.EqualFold(resolvedMode, "phone") {
|
||||
width = unit.Dp(412)
|
||||
height = unit.Dp(915)
|
||||
}
|
||||
|
||||
go func() {
|
||||
w := new(app.Window)
|
||||
options := []app.Option{app.Title(productName)}
|
||||
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
||||
options = append(options, app.Size(width, height))
|
||||
}
|
||||
w.Option(options...)
|
||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func defaultGRPCAddr(goos string) string {
|
||||
return grpcaddr.Default(goos)
|
||||
}
|
||||
|
||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||
ensureBrowserNativeHosts()
|
||||
var ops op.Ops
|
||||
manager := &session.Manager{}
|
||||
ui := newUIWithSession(mode, manager, paths)
|
||||
ui.fileExplorer = explorer.NewExplorer(w)
|
||||
ui.invalidate = w.Invalidate
|
||||
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||
if err != nil {
|
||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||
} else if host != nil {
|
||||
ui.apiHost = host
|
||||
ui.auditLog = host.Server().AuditLog()
|
||||
ui.state.AuditLog = ui.auditLog
|
||||
ui.grpcAddress = host.Address()
|
||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||
host.Server().SetChangeNotifier(func() {
|
||||
ui.state.Dirty = true
|
||||
ui.invalidate()
|
||||
})
|
||||
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
||||
defer func() { _ = host.Stop() }()
|
||||
}
|
||||
for {
|
||||
e := w.Event()
|
||||
ui.fileExplorer.ListenEvents(e)
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
ui.processBackgroundActions()
|
||||
ui.layout(gtx)
|
||||
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureBrowserNativeHosts() {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
appBinaryPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
server *api.Server
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
||||
if m == nil || m.server == nil {
|
||||
return nil
|
||||
}
|
||||
return m.server.ApprovalBroker().Pending()
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||
if m == nil || m.server == nil {
|
||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
||||
}
|
||||
return m.server.ResolveApproval(id, outcome)
|
||||
}
|
||||
|
||||
type uiSession struct {
|
||||
model vault.Model
|
||||
locked bool
|
||||
}
|
||||
|
||||
func (s *uiSession) HasVault() bool {
|
||||
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsLocked() bool {
|
||||
return s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsRemote() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *uiSession) Current() (vault.Model, error) {
|
||||
if s.locked {
|
||||
return vault.Model{}, session.ErrLocked
|
||||
}
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
|
||||
func (s *uiSession) Lock() error {
|
||||
s.locked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Unlock(vault.MasterKey) error {
|
||||
if !s.locked {
|
||||
return nil
|
||||
}
|
||||
s.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickExistingFile() (string, error) {
|
||||
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
||||
}
|
||||
|
||||
func runFilePicker(name string, args ...string) (string, error) {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsePickedFilePath(output)
|
||||
}
|
||||
|
||||
func parsePickedFilePath(output []byte) (string, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("file picker did not return a path")
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,47 +11,56 @@ import (
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
displayDensityDense = "dense"
|
||||
displayDensityComfortable = "comfortable"
|
||||
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||
|
||||
contrastStandard = "standard"
|
||||
contrastHigh = "high"
|
||||
contrastStandard = settingsmodel.ContrastStandard
|
||||
contrastHigh = settingsmodel.ContrastHigh
|
||||
|
||||
keyboardFocusStandard = "standard"
|
||||
keyboardFocusProminent = "prominent"
|
||||
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||
)
|
||||
|
||||
type accessibilityPreferences struct {
|
||||
DisplayDensity string
|
||||
Contrast string
|
||||
ReducedMotion bool
|
||||
KeyboardFocus string
|
||||
}
|
||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||
|
||||
type settingsFile struct {
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Debug debugSettings `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettings struct {
|
||||
SourceDefault string `json:"sourceDefault,omitempty"`
|
||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
|
||||
}
|
||||
|
||||
type debugSettings struct {
|
||||
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettingsDraft struct {
|
||||
SourceDefault syncSourceMode
|
||||
DirectionDefault syncDirection
|
||||
AutoSaveRemote bool
|
||||
}
|
||||
|
||||
type settingsDraft struct {
|
||||
Accessibility accessibilityPreferences
|
||||
Sync syncSettingsDraft
|
||||
Debug debugSettings
|
||||
}
|
||||
|
||||
type legacySyncPreferences struct {
|
||||
@@ -63,37 +74,23 @@ type choiceSpec struct {
|
||||
Active bool
|
||||
}
|
||||
|
||||
type focusAppearance struct {
|
||||
BorderColor color.NRGBA
|
||||
OutlineColor color.NRGBA
|
||||
OutlineWidth int
|
||||
MinHeight int
|
||||
}
|
||||
|
||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||
return accessibilityPreferences{
|
||||
DisplayDensity: displayDensityForDenseLayout(true),
|
||||
Contrast: contrastStandard,
|
||||
KeyboardFocus: keyboardFocusStandard,
|
||||
}
|
||||
return settingsmodel.DefaultAccessibilityPreferences()
|
||||
}
|
||||
|
||||
func displayDensityForDenseLayout(dense bool) string {
|
||||
if dense {
|
||||
return displayDensityDense
|
||||
}
|
||||
return displayDensityComfortable
|
||||
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||
}
|
||||
|
||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||
normalized := defaultAccessibilityPreferences()
|
||||
switch prefs.DisplayDensity {
|
||||
case displayDensityDense, displayDensityComfortable:
|
||||
normalized.DisplayDensity = prefs.DisplayDensity
|
||||
}
|
||||
switch prefs.Contrast {
|
||||
case contrastStandard, contrastHigh:
|
||||
normalized.Contrast = prefs.Contrast
|
||||
}
|
||||
switch prefs.KeyboardFocus {
|
||||
case keyboardFocusStandard, keyboardFocusProminent:
|
||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||
}
|
||||
normalized.ReducedMotion = prefs.ReducedMotion
|
||||
return normalized
|
||||
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||
}
|
||||
|
||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
@@ -102,6 +99,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
u.accessibilityPrefs = normalized
|
||||
}
|
||||
|
||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
appearance := focusAppearance{
|
||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||
OutlineColor: color.NRGBA{A: 0},
|
||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||
MinHeight: metric.Dp(unit.Dp(44)),
|
||||
}
|
||||
if prefs.DisplayDensity == displayDensityComfortable {
|
||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||
}
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||
}
|
||||
if focused {
|
||||
appearance.BorderColor = accentColor
|
||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||
}
|
||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||
}
|
||||
}
|
||||
return appearance
|
||||
}
|
||||
|
||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||
text = accentColor
|
||||
if prefs.Contrast == contrastHigh {
|
||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
}
|
||||
if focused {
|
||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||
}
|
||||
}
|
||||
return background, text
|
||||
}
|
||||
|
||||
func (u *ui) accessibilityLabel(id focusID) string {
|
||||
switch {
|
||||
case id == focusSearch:
|
||||
return "Search vault"
|
||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||
index := focusIndex(id)
|
||||
crumbs := u.breadcrumbLabels()
|
||||
if index >= 0 && index < len(crumbs) {
|
||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||
}
|
||||
case strings.HasPrefix(string(id), "list:"):
|
||||
index := focusIndex(id)
|
||||
if index >= 0 && index < len(u.visible) {
|
||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||
}
|
||||
case strings.HasPrefix(string(id), "detail:"):
|
||||
name := strings.TrimPrefix(string(id), "detail:")
|
||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
width := appearance.OutlineWidth
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
func (u *ui) isFocused(id focusID) bool {
|
||||
return u.keyboardFocus == id
|
||||
}
|
||||
|
||||
func detailFieldLabel(field detailField) string {
|
||||
return editormodel.Label(field)
|
||||
}
|
||||
|
||||
func (u *ui) loadSettingsDraft() {
|
||||
u.settingsDraft = settingsDraft{
|
||||
Accessibility: accessibilityPreferences{
|
||||
@@ -113,8 +200,14 @@ func (u *ui) loadSettingsDraft() {
|
||||
Sync: syncSettingsDraft{
|
||||
SourceDefault: u.syncDefaultSourceMode,
|
||||
DirectionDefault: u.syncDefaultDirection,
|
||||
AutoSaveRemote: u.autoSaveRemote,
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}
|
||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
|
||||
}
|
||||
|
||||
func (u *ui) saveSecuritySettingsAction() error {
|
||||
@@ -136,9 +229,17 @@ func (u *ui) applySecuritySettingsLive() error {
|
||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||
}
|
||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
|
||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
|
||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||
if !u.debugLogHeaderBounds {
|
||||
u.lastHeaderBoundsLog = ""
|
||||
}
|
||||
u.applySettingsFormToPreferences()
|
||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||
u.saveSettings()
|
||||
@@ -149,6 +250,7 @@ func (u *ui) applySecuritySettingsLive() error {
|
||||
func (u *ui) loadSettings() {
|
||||
u.syncDefaultSourceMode = syncSourceLocal
|
||||
u.syncDefaultDirection = syncDirectionPull
|
||||
u.autoSaveRemote = false
|
||||
|
||||
if strings.TrimSpace(u.settingsPath) != "" {
|
||||
content, err := os.ReadFile(u.settingsPath)
|
||||
@@ -157,12 +259,16 @@ func (u *ui) loadSettings() {
|
||||
if json.Unmarshal(content, &settings) == nil {
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||
u.autoSaveRemote = settings.Sync.AutoSaveRemote
|
||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.loadLegacySyncDefaultsFromUIPreferences()
|
||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
||||
}
|
||||
|
||||
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
||||
@@ -192,6 +298,10 @@ func (u *ui) saveSettings() {
|
||||
Sync: syncSettings{
|
||||
SourceDefault: string(u.syncDefaultSourceMode),
|
||||
DirectionDefault: string(u.syncDefaultDirection),
|
||||
AutoSaveRemote: u.autoSaveRemote,
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
@@ -200,6 +310,44 @@ func (u *ui) saveSettings() {
|
||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
line := bounds.logLine(u.mode)
|
||||
if line == u.lastHeaderBoundsLog {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", line)
|
||||
u.lastHeaderBoundsLog = line
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf(
|
||||
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
|
||||
menu,
|
||||
placement.Anchor.TriggerRightX,
|
||||
placement.Anchor.TriggerBottomY,
|
||||
placement.Origin.X,
|
||||
placement.Origin.Y,
|
||||
placement.Size.X,
|
||||
placement.Size.Y,
|
||||
surface.ContainerWidth,
|
||||
surface.LeftInset,
|
||||
surface.TopInset,
|
||||
))
|
||||
}
|
||||
|
||||
func (u *ui) showStatusMessage(message string) {
|
||||
u.state.StatusMessage = message
|
||||
if u.accessibilityPrefs.ReducedMotion {
|
||||
@@ -0,0 +1,50 @@
|
||||
package settings
|
||||
|
||||
type AccessibilityPreferences struct {
|
||||
DisplayDensity string
|
||||
Contrast string
|
||||
ReducedMotion bool
|
||||
KeyboardFocus string
|
||||
}
|
||||
|
||||
const (
|
||||
DisplayDensityDense = "dense"
|
||||
DisplayDensityComfortable = "comfortable"
|
||||
ContrastStandard = "standard"
|
||||
ContrastHigh = "high"
|
||||
KeyboardFocusStandard = "standard"
|
||||
KeyboardFocusProminent = "prominent"
|
||||
)
|
||||
|
||||
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
||||
return AccessibilityPreferences{
|
||||
DisplayDensity: DisplayDensityDense,
|
||||
Contrast: ContrastStandard,
|
||||
KeyboardFocus: KeyboardFocusStandard,
|
||||
}
|
||||
}
|
||||
|
||||
func DisplayDensityForDenseLayout(dense bool) string {
|
||||
if dense {
|
||||
return DisplayDensityDense
|
||||
}
|
||||
return DisplayDensityComfortable
|
||||
}
|
||||
|
||||
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
||||
normalized := DefaultAccessibilityPreferences()
|
||||
switch prefs.DisplayDensity {
|
||||
case DisplayDensityDense, DisplayDensityComfortable:
|
||||
normalized.DisplayDensity = prefs.DisplayDensity
|
||||
}
|
||||
switch prefs.Contrast {
|
||||
case ContrastStandard, ContrastHigh:
|
||||
normalized.Contrast = prefs.Contrast
|
||||
}
|
||||
switch prefs.KeyboardFocus {
|
||||
case KeyboardFocusStandard, KeyboardFocusProminent:
|
||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||
}
|
||||
normalized.ReducedMotion = prefs.ReducedMotion
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package sync
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
||||
|
||||
type SourceMode string
|
||||
|
||||
const (
|
||||
SourceLocal SourceMode = "local"
|
||||
SourceRemote SourceMode = "remote"
|
||||
)
|
||||
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionPull Direction = "pull"
|
||||
DirectionPush Direction = "push"
|
||||
)
|
||||
|
||||
type DialogPurpose string
|
||||
|
||||
const (
|
||||
DialogPurposeAdvanced DialogPurpose = "advanced"
|
||||
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
||||
)
|
||||
|
||||
type MenuModel struct {
|
||||
HasOpenVault bool
|
||||
HasSelectedBinding bool
|
||||
ShowSelectors bool
|
||||
ShowShare bool
|
||||
ShowSaveCurrentBinding bool
|
||||
SavedBindingSummary MenuBindingSummary
|
||||
RemoteBaseURL string
|
||||
RemotePath string
|
||||
RemoteUsername string
|
||||
RemotePassword string
|
||||
SelectedVaultSyncMode appstate.SyncMode
|
||||
}
|
||||
|
||||
type MenuBindingSummary struct {
|
||||
ProfileLabel string
|
||||
CredentialLabel string
|
||||
SyncLabel string
|
||||
OK bool
|
||||
}
|
||||
|
||||
func (m MenuModel) SavedBindingHeading() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use this vault's saved remote sync target"
|
||||
}
|
||||
return "Use a saved remote profile from this vault"
|
||||
}
|
||||
|
||||
func (m MenuModel) OpenSelectedButtonLabel() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use Remote Sync"
|
||||
}
|
||||
return "Open Saved Remote"
|
||||
}
|
||||
|
||||
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
||||
|
||||
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
||||
|
||||
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
||||
return m.HasOpenVault && !m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
||||
|
||||
func (m MenuModel) ActionLabels() []string {
|
||||
labels := []string{"Open Advanced Sync"}
|
||||
if m.ShowRemoteSyncSetupShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
||||
}
|
||||
if m.ShowDirectRemoteSyncShortcut() {
|
||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoteSyncSettingsShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoveRemoteSyncShortcut() {
|
||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
||||
return "Bind this local vault to the current remote target"
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
||||
|
||||
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
||||
if purpose == DialogPurposeRemoteSetup {
|
||||
return "Push this local vault to a WebDAV target and save that target for future sync."
|
||||
}
|
||||
sourceLabel := "another local vault file"
|
||||
if source == SourceRemote {
|
||||
sourceLabel = "another WebDAV-backed vault"
|
||||
}
|
||||
action := "Pull changes from"
|
||||
if direction == DirectionPush {
|
||||
action = "Push the current vault into"
|
||||
}
|
||||
return action + " " + sourceLabel + "."
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(620))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||
}
|
||||
if width < 1 {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return card(gtx, u.syncDialogContent)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
||||
}
|
||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.syncSourceMode == syncSourceRemote {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncPasswordField(gtx)
|
||||
}),
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
||||
check.Color = accentColor
|
||||
return check.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
if len(matchingCredentials) > 0 {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
for i, entry := range matchingCredentials {
|
||||
i := i
|
||||
entry := entry
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
label += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
field := func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.outlinedFieldState(gtx, false, field)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
@@ -0,0 +1,346 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
NativeHostName = "com.keepassgo.browser"
|
||||
defaultFirefoxID = "browser@keepassgo.com"
|
||||
maxNativeMessageSize = 1024 * 1024
|
||||
chromiumIDBytes = 16
|
||||
responseVersion = "1"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
Matches []Match `json:"matches,omitempty"`
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Locked bool `json:"locked"`
|
||||
Dirty bool `json:"dirty,omitempty"`
|
||||
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
|
||||
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
GRPCAddress string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||
}
|
||||
|
||||
type Browser string
|
||||
|
||||
const (
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserChromium Browser = "chromium"
|
||||
)
|
||||
|
||||
type NativeHostManifest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultFirefoxExtensionID() string {
|
||||
return defaultFirefoxID
|
||||
}
|
||||
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||
if size == 0 || size > maxNativeMessageSize {
|
||||
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||
}
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func WriteResponse(w io.Writer, resp Response) error {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode native response: %w", err)
|
||||
}
|
||||
if len(data) > maxNativeMessageSize {
|
||||
return fmt.Errorf("native response too large: %d", len(data))
|
||||
}
|
||||
var sizeBuf [4]byte
|
||||
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Request) Connection(grpcAddr string) (Connection, error) {
|
||||
return normalizeConnection(Connection{
|
||||
GRPCAddress: strings.TrimSpace(grpcAddr),
|
||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeConnection(conn Connection) (Connection, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||
}
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
|
||||
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error()}
|
||||
}
|
||||
action := strings.TrimSpace(req.Action)
|
||||
switch action {
|
||||
case "status":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: responseVersion}
|
||||
case "find-logins":
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
||||
case "get-login":
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
||||
default:
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectedStatus(addr string) *Status {
|
||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func availableStatus(addr string) *Status {
|
||||
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func inferredActionStatus(addr string, err error) *Status {
|
||||
switch gstatus.Code(err) {
|
||||
case gcodes.FailedPrecondition:
|
||||
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
|
||||
case gcodes.OK:
|
||||
return availableStatus(addr)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||
resp, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Status{
|
||||
Connected: true,
|
||||
Locked: resp.GetLocked(),
|
||||
Dirty: resp.GetDirty(),
|
||||
EntryCount: resp.GetEntryCount(),
|
||||
PendingApprovalCount: resp.GetPendingApprovalCount(),
|
||||
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
|
||||
GRPCAddress: strings.TrimSpace(addr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Match, 0, len(resp))
|
||||
for _, match := range resp {
|
||||
out = append(out, Match{
|
||||
ID: match.GetId(),
|
||||
Title: match.GetTitle(),
|
||||
Username: match.GetUsername(),
|
||||
URL: match.GetUrl(),
|
||||
Path: append([]string(nil), match.GetPath()...),
|
||||
Quality: match.GetQuality(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||
id := strings.TrimSpace(entryID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("entry id is required")
|
||||
}
|
||||
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Credential{
|
||||
ID: resp.GetId(),
|
||||
Username: resp.GetUsername(),
|
||||
Password: resp.GetPassword(),
|
||||
URL: resp.GetUrl(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||
path := strings.TrimSpace(binaryPath)
|
||||
if path == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
id = defaultFirefoxID
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedExtensions: []string{id},
|
||||
}, nil
|
||||
case BrowserChrome, BrowserChromium:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||
}, nil
|
||||
default:
|
||||
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
|
||||
normalized := strings.TrimSpace(raw)
|
||||
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
|
||||
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\n", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\t", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("chromium extension key is required")
|
||||
}
|
||||
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode chromium extension key: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(publicKeyDER)
|
||||
var builder strings.Builder
|
||||
builder.Grow(chromiumIDBytes * 2)
|
||||
for _, b := range hash[:chromiumIDBytes] {
|
||||
builder.WriteByte('a' + ((b >> 4) & 0x0f))
|
||||
builder.WriteByte('a' + (b & 0x0f))
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func DefaultManifestPath(browser Browser) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||
case BrowserChrome:
|
||||
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
case BrowserChromium:
|
||||
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var input bytes.Buffer
|
||||
body, err := json.Marshal(Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
if _, err := input.Write(body); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
req, err := ReadRequest(&input)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRequest() error = %v", err)
|
||||
}
|
||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||
}
|
||||
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||
t.Fatalf("WriteResponse() error = %v", err)
|
||||
}
|
||||
var size uint32
|
||||
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||
t.Fatalf("binary.Read() error = %v", err)
|
||||
}
|
||||
payload := make([]byte, size)
|
||||
if _, err := output.Read(payload); err != nil {
|
||||
t.Fatalf("Read() payload error = %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Version != "1" {
|
||||
t.Fatalf("response = %#v, want success version 1", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLogins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{
|
||||
matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{
|
||||
Locked: false,
|
||||
EntryCount: 2,
|
||||
PendingApprovalCount: 3,
|
||||
TokenPendingApprovalCount: 1,
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "status",
|
||||
BearerToken: "secret",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Status == nil {
|
||||
t.Fatal("HandleRequest(status).Status = nil, want status")
|
||||
}
|
||||
if got := resp.Status.PendingApprovalCount; got != 3 {
|
||||
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
|
||||
}
|
||||
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
|
||||
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestGetLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{
|
||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: "vault-console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Url: "https://vault.example.invalid",
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "get-login",
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Status == nil || !resp.Status.Locked {
|
||||
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
|
||||
if resp.Success {
|
||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := Request{Action: "status", BearerToken: "secret"}
|
||||
conn, err := req.Connection("")
|
||||
if err != nil {
|
||||
t.Fatalf("Connection(\"\") error = %v", err)
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||
}
|
||||
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
|
||||
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||
}
|
||||
|
||||
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("InstallManifest() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var manifest NativeHostManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if manifest.Path != binaryPath {
|
||||
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||
}
|
||||
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
|
||||
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
|
||||
}
|
||||
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
|
||||
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
|
||||
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ManifestSet() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
|
||||
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
|
||||
}
|
||||
if !slices.Equal(manifest.AllowedOrigins, want) {
|
||||
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
|
||||
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
|
||||
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
|
||||
|
||||
got, err := DiscoverInstalledExtensionIDsInRoot(root)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
||||
"mjlnpdomnblnbblhacolncflebbgafhj",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", filepath.Join(tmp, "home"))
|
||||
appDir := filepath.Join(tmp, "app")
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(appDir) error = %v", err)
|
||||
}
|
||||
appBinaryPath := filepath.Join(appDir, "keepassgo")
|
||||
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
|
||||
}
|
||||
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
|
||||
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
|
||||
}
|
||||
home := filepath.Join(tmp, "home")
|
||||
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
||||
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
|
||||
|
||||
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
|
||||
}
|
||||
|
||||
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
|
||||
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
|
||||
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
status *keepassgov1.GetSessionStatusResponse
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
matchesErr error
|
||||
credentialErr error
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
func writeExtensionManifest(t *testing.T, path, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||
}
|
||||
data, err := json.Marshal(map[string]string{"name": name})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal(%q) error = %v", path, err)
|
||||
}
|
||||
valuesAny, ok := manifest[field]
|
||||
if !ok {
|
||||
t.Fatalf("manifest %q missing field %q", path, field)
|
||||
}
|
||||
valuesRaw, ok := valuesAny.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
|
||||
}
|
||||
values := make([]string, 0, len(valuesRaw))
|
||||
for _, raw := range valuesRaw {
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
|
||||
}
|
||||
values = append(values, text)
|
||||
}
|
||||
if !slices.Contains(values, want) {
|
||||
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
f.statusCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.status == nil {
|
||||
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
if f.matchesErr != nil {
|
||||
return nil, f.matchesErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.matches, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
if f.credentialErr != nil {
|
||||
return nil, f.credentialErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.credential == nil {
|
||||
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||
}
|
||||
return f.credential, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type GRPCClient struct {
|
||||
client keepassgov1.VaultServiceClient
|
||||
}
|
||||
|
||||
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return Dial(ctx, conn)
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
normalized, err := normalizeConnection(conn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
target := endpoint
|
||||
if network == "unix" {
|
||||
target = "passthrough:///" + endpoint
|
||||
}
|
||||
grpcConn, err := grpc.NewClient(target,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial(network, endpoint)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
|
||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||
}
|
||||
|
||||
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetMatches(), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: strings.TrimSpace(entryID),
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const browserExtensionName = "KeePassGO Browser"
|
||||
|
||||
type extensionManifestMetadata struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
|
||||
path := strings.TrimSpace(appBinaryPath)
|
||||
if path == "" {
|
||||
var err error
|
||||
path, err = os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve app executable: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("app executable path is required")
|
||||
}
|
||||
if filepath.Base(path) == "keepassgo-browser-bridge" {
|
||||
return path, nil
|
||||
}
|
||||
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
resolved, err := exec.LookPath("keepassgo-browser-bridge")
|
||||
if err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
|
||||
}
|
||||
|
||||
func EnsureNativeHostManifests(appBinaryPath string) error {
|
||||
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
|
||||
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
|
||||
}
|
||||
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
|
||||
ids, err := DiscoverInstalledExtensionIDs(browser)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
|
||||
continue
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
|
||||
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
|
||||
root, err := defaultBrowserProfileRoot(browser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DiscoverInstalledExtensionIDsInRoot(root)
|
||||
}
|
||||
|
||||
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
|
||||
base := strings.TrimSpace(root)
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("browser profile root is required")
|
||||
}
|
||||
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
|
||||
paths, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("glob browser extensions: %w", err)
|
||||
}
|
||||
ids := make(map[string]struct{}, len(paths))
|
||||
for _, path := range paths {
|
||||
ok, err := isKeePassGOExtensionManifest(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
|
||||
if strings.TrimSpace(id) == "" {
|
||||
continue
|
||||
}
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
out = append(out, id)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
|
||||
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" {
|
||||
path, err = DefaultManifestPath(browser)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
|
||||
path := strings.TrimSpace(binaryPath)
|
||||
if path == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
return Manifest(browser, path, "")
|
||||
case BrowserChrome, BrowserChromium:
|
||||
ids := normalizedExtensionIDs(extensionIDs)
|
||||
if len(ids) == 0 {
|
||||
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||
}
|
||||
origins := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
origins = append(origins, "chrome-extension://"+id+"/")
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedOrigins: origins,
|
||||
}, nil
|
||||
default:
|
||||
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultBrowserProfileRoot(browser Browser) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch browser {
|
||||
case BrowserChrome:
|
||||
return filepath.Join(home, ".config", "google-chrome"), nil
|
||||
case BrowserChromium:
|
||||
return filepath.Join(home, ".config", "chromium"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func isKeePassGOExtensionManifest(path string) (bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
|
||||
}
|
||||
var metadata extensionManifestMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
|
||||
}
|
||||
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
|
||||
}
|
||||
|
||||
func normalizedExtensionIDs(ids []string) []string {
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, raw := range ids {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
systemclipboard "github.com/atotto/clipboard"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
@@ -0,0 +1,66 @@
|
||||
package grpcaddr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const socketName = "keepassgo-grpc.sock"
|
||||
|
||||
func Default(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
|
||||
return "127.0.0.1:47777"
|
||||
}
|
||||
return "unix://" + DefaultSocketPath()
|
||||
}
|
||||
|
||||
func DefaultSocketPath() string {
|
||||
return filepath.Join(runtimeDir(), "keepassgo", socketName)
|
||||
}
|
||||
|
||||
func runtimeDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
runUserDir := filepath.Join("/run/user", uid)
|
||||
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
|
||||
return runUserDir
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
|
||||
}
|
||||
|
||||
func Parse(raw string) (network, endpoint string, err error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case value == "":
|
||||
return "", "", fmt.Errorf("gRPC address is required")
|
||||
case strings.EqualFold(value, "off"):
|
||||
return "", "", nil
|
||||
case strings.HasPrefix(value, "unix://"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("unix gRPC socket path is required")
|
||||
}
|
||||
return "unix", path, nil
|
||||
case strings.HasPrefix(value, "tcp://"):
|
||||
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
|
||||
if addr == "" {
|
||||
return "", "", fmt.Errorf("tcp gRPC address is required")
|
||||
}
|
||||
return "tcp", addr, nil
|
||||
case strings.HasPrefix(value, "/"):
|
||||
return "unix", value, nil
|
||||
default:
|
||||
return "tcp", value, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package grpcaddr
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix default is not expected on windows")
|
||||
}
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
|
||||
|
||||
got := Default("linux")
|
||||
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
|
||||
if got != want {
|
||||
t.Fatalf("Default() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNetwork string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
|
||||
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
|
||||
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotNetwork, gotEnd, err := Parse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
|
||||
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -31,6 +32,7 @@ type Manager struct {
|
||||
remoteClient *webdav.Client
|
||||
remotePath string
|
||||
remoteVersion webdav.Version
|
||||
warning string
|
||||
}
|
||||
|
||||
type PreparedLocalOpen struct {
|
||||
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
|
||||
Key vault.MasterKey
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
Warning string
|
||||
}
|
||||
|
||||
type PreparedRemoteOpen struct {
|
||||
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
RemoteVersion webdav.Version
|
||||
Warning string
|
||||
}
|
||||
|
||||
type PreparedUnlock struct {
|
||||
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
|
||||
Config *vault.KDBXConfig
|
||||
Key vault.MasterKey
|
||||
VaultRoot string
|
||||
Warning string
|
||||
}
|
||||
|
||||
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
||||
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||
root := detectSingleVaultRoot(model)
|
||||
root := vaultview.KeepassRoot
|
||||
model = normalizeUnderRoot(model, root)
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
||||
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||
m.vaultRoot = root
|
||||
m.encoded = encoded.Bytes()
|
||||
m.locked = false
|
||||
m.warning = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,6 +99,10 @@ func (m *Manager) HasVault() bool {
|
||||
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
||||
}
|
||||
|
||||
func (m *Manager) HasSaveTarget() bool {
|
||||
return m.path != "" || (m.remoteClient != nil && m.remotePath != "")
|
||||
}
|
||||
|
||||
func (m *Manager) EncodedBytes() []byte {
|
||||
return append([]byte(nil), m.encoded...)
|
||||
}
|
||||
@@ -114,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConsumeWarning() string {
|
||||
warning := strings.TrimSpace(m.warning)
|
||||
m.warning = ""
|
||||
return warning
|
||||
}
|
||||
|
||||
func (m *Manager) Save() error {
|
||||
if m.remoteClient != nil && m.remotePath != "" {
|
||||
return m.SaveRemote()
|
||||
@@ -250,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
|
||||
func (m *Manager) Replace(model vault.Model) {
|
||||
root := m.vaultRoot
|
||||
if root == "" {
|
||||
root = detectSingleVaultRoot(model)
|
||||
root = vaultview.KeepassRoot
|
||||
}
|
||||
m.model = normalizeUnderRoot(model, root)
|
||||
m.vaultRoot = root
|
||||
@@ -301,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
|
||||
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
return PreparedLocalOpen{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -320,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
|
||||
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
|
||||
}
|
||||
return PreparedRemoteOpen{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Client: client,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
RemoteVersion: version,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -337,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
|
||||
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
return PreparedUnlock{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Key: key,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -355,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
||||
m.remoteClient = nil
|
||||
m.remotePath = ""
|
||||
m.remoteVersion = webdav.Version{}
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||
@@ -368,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||
m.remotePath = prepared.Path
|
||||
m.remoteVersion = prepared.RemoteVersion
|
||||
m.path = ""
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||
@@ -376,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||
m.key = prepared.Key
|
||||
m.vaultRoot = prepared.VaultRoot
|
||||
m.locked = false
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||
@@ -580,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
||||
return err
|
||||
}
|
||||
m.model = merged
|
||||
if root := detectSingleVaultRoot(merged); root != "" {
|
||||
m.vaultRoot = root
|
||||
}
|
||||
m.vaultRoot = vaultview.KeepassRoot
|
||||
m.encoded = encoded
|
||||
m.locked = false
|
||||
return nil
|
||||
@@ -599,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
||||
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
||||
}
|
||||
m.model = merged
|
||||
if root := detectSingleVaultRoot(merged); root != "" {
|
||||
m.vaultRoot = root
|
||||
}
|
||||
m.vaultRoot = vaultview.KeepassRoot
|
||||
m.encoded = encoded
|
||||
m.remoteVersion = version
|
||||
m.locked = false
|
||||
@@ -863,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func detectSingleVaultRoot(model vault.Model) string {
|
||||
if len(model.EntriesInPath(nil)) != 0 {
|
||||
return ""
|
||||
}
|
||||
groups := model.ChildGroups(nil)
|
||||
if len(groups) != 1 {
|
||||
return ""
|
||||
}
|
||||
return groups[0]
|
||||
}
|
||||
|
||||
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
if root == "" {
|
||||
return model
|
||||
@@ -884,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
switch {
|
||||
case len(path) == 0:
|
||||
return []string{root}
|
||||
case path[0] == "Root":
|
||||
if len(path) == 1 {
|
||||
return []string{root}
|
||||
}
|
||||
return append([]string{root}, path[1:]...)
|
||||
case path[0] == root:
|
||||
return path
|
||||
case path[0] == "Templates":
|
||||
return path
|
||||
default:
|
||||
return append([]string{root}, path...)
|
||||
}
|
||||
@@ -903,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
|
||||
}
|
||||
}
|
||||
for i := range out.Templates {
|
||||
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
|
||||
for j := range out.Templates[i].History {
|
||||
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
|
||||
}
|
||||
}
|
||||
for i := range out.Groups {
|
||||
out.Groups[i] = normalizePath(out.Groups[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizationWarning(model vault.Model) string {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return ""
|
||||
}
|
||||
if usesKeepassStorageRoot(model) {
|
||||
return ""
|
||||
}
|
||||
return "Opened legacy vault root layout and normalized it under keepass."
|
||||
}
|
||||
|
||||
func usesKeepassStorageRoot(model vault.Model) bool {
|
||||
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||