Compare commits
2 Commits
v0.5.0
..
a4b3d5c1e1
| Author | SHA1 | Date | |
|---|---|---|---|
| a4b3d5c1e1 | |||
| ce01c47256 |
@@ -1,122 +0,0 @@
|
|||||||
---
|
|
||||||
name: keepassgo-apk-test
|
|
||||||
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
|
|
||||||
---
|
|
||||||
|
|
||||||
# KeePassGO APK Test
|
|
||||||
|
|
||||||
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
|
|
||||||
|
|
||||||
## Use This Skill When
|
|
||||||
|
|
||||||
- Building `build/keepassgo.apk`.
|
|
||||||
- Installing or launching KeePassGO in the Android emulator.
|
|
||||||
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
|
|
||||||
- Checking whether a Gio or Android toolchain change broke runtime behavior.
|
|
||||||
|
|
||||||
## Known Working Environment
|
|
||||||
|
|
||||||
- Preferred emulator AVD: `KeepassGoAPI35`
|
|
||||||
- Package: `org.julianfamily.keepassgo`
|
|
||||||
- Activity: `org.gioui.GioActivity`
|
|
||||||
- Local APK build defaults:
|
|
||||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
|
||||||
`ANDROID_NDK_ROOT=/opt/android-ndk`
|
|
||||||
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
|
|
||||||
- CI APK build uses:
|
|
||||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
|
||||||
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
|
|
||||||
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
|
|
||||||
|
|
||||||
## Known Regression
|
|
||||||
|
|
||||||
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
|
|
||||||
- `gioui.org v0.8.0` rendered correctly in the same environment.
|
|
||||||
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
|
|
||||||
|
|
||||||
## Safety Rules
|
|
||||||
|
|
||||||
- Do not clobber the user’s real KeePassGO state while testing.
|
|
||||||
- For host-side validation, use isolated state such as:
|
|
||||||
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
|
|
||||||
- Prefer sanitized demo or test vaults when seeding emulator tests.
|
|
||||||
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the user’s existing recent-vault state.
|
|
||||||
|
|
||||||
## Build Workflow
|
|
||||||
|
|
||||||
1. Verify the JDK/SDK paths match the known working environment.
|
|
||||||
2. Build with `make apk` for debug validation, or `make apk-release` when validating production signing behavior.
|
|
||||||
3. If the build fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
|
|
||||||
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
|
|
||||||
|
|
||||||
Typical local build:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
|
||||||
```
|
|
||||||
|
|
||||||
Typical local release build:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Emulator Workflow
|
|
||||||
|
|
||||||
1. Reuse an existing emulator session if one is already running.
|
|
||||||
2. Prefer the `KeepassGoAPI35` AVD.
|
|
||||||
3. Install with `adb install -r build/keepassgo.apk`.
|
|
||||||
4. Launch with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Confirm focus before drawing conclusions:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Capture evidence before editing code:
|
|
||||||
screenshot
|
|
||||||
focused window
|
|
||||||
relevant `logcat`
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
- APK builds successfully with the intended target: `make apk` for debug validation or `make apk-release` for release-signing validation.
|
|
||||||
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
|
|
||||||
- Screenshot shows the expected screen, not just a black frame.
|
|
||||||
- `logcat` shows no app crash or Android runtime fatal error.
|
|
||||||
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
|
|
||||||
|
|
||||||
## Issue-Specific Checks
|
|
||||||
|
|
||||||
### Black Screen
|
|
||||||
|
|
||||||
- Confirm the app is focused.
|
|
||||||
- Capture a screenshot and `logcat` before changing anything.
|
|
||||||
- Compare the current Gio version against the known `v0.9.0` regression history.
|
|
||||||
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
|
|
||||||
|
|
||||||
### Clipboard
|
|
||||||
|
|
||||||
- Verify behavior in the emulator, not just unit tests.
|
|
||||||
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
|
|
||||||
|
|
||||||
### File Picker Or Local Sync
|
|
||||||
|
|
||||||
- Verify the picker flow on Android itself.
|
|
||||||
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
|
|
||||||
|
|
||||||
## Report Back
|
|
||||||
|
|
||||||
When closing out work, state:
|
|
||||||
|
|
||||||
- the exact build command used
|
|
||||||
- whether the emulator session was reused or newly started
|
|
||||||
- whether install succeeded
|
|
||||||
- whether the app actually rendered
|
|
||||||
- what was verified manually in the emulator
|
|
||||||
- what remains unverified
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
---
|
|
||||||
name: keepassgo-ship-it
|
|
||||||
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
|
||||||
---
|
|
||||||
|
|
||||||
# KeePassGO Ship It
|
|
||||||
|
|
||||||
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
|
||||||
|
|
||||||
Use it together with:
|
|
||||||
- `android-emulator-debug` for emulator and `adb` mechanics
|
|
||||||
- `keepass-credentials` for Nextcloud credentials
|
|
||||||
- `public-repo-sanitization` before the commit/push step
|
|
||||||
|
|
||||||
## Meaning Of `ship it`
|
|
||||||
|
|
||||||
When the user says `ship it`, do all of this unless they narrow the scope:
|
|
||||||
|
|
||||||
1. Commit the relevant KeePassGO source changes first.
|
|
||||||
2. Build and install the Arch package from that committed source.
|
|
||||||
3. Build the Android APK from that same committed source.
|
|
||||||
4. Zip the APK.
|
|
||||||
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
|
||||||
6. Install the rebuilt APK in the emulator.
|
|
||||||
7. Launch the rebuilt app in the emulator.
|
|
||||||
8. Open a controlled demo vault in the emulator.
|
|
||||||
|
|
||||||
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
|
||||||
|
|
||||||
## Required Sequence
|
|
||||||
|
|
||||||
### 1. Commit First
|
|
||||||
|
|
||||||
- Make sure the worktree state intended for shipping is committed before building.
|
|
||||||
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
|
||||||
- Before the commit or push, run the public-repo sanitization checks.
|
|
||||||
|
|
||||||
### 2. Build And Install The Arch Package
|
|
||||||
|
|
||||||
From the repo root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make archlinux-pkgbuild
|
|
||||||
cd packaging/archlinux/keepassgo-git
|
|
||||||
makepkg -si --noconfirm
|
|
||||||
```
|
|
||||||
|
|
||||||
The installed package version must correspond to the committed source, not a dirty worktree.
|
|
||||||
|
|
||||||
### 3. Build The APK
|
|
||||||
|
|
||||||
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
|
|
||||||
```
|
|
||||||
|
|
||||||
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
|
||||||
|
|
||||||
- `ship it` must use the dedicated release keystore flow, not Gio's implicit debug or temporary signing path.
|
|
||||||
- The default local release-signing paths are:
|
|
||||||
`~/.config/keepassgo/android-release.keystore`
|
|
||||||
`~/.config/keepassgo/android-release.pass`
|
|
||||||
- If those files are unavailable, stop and fix signing instead of shipping a differently signed APK.
|
|
||||||
|
|
||||||
### 4. Zip The APK
|
|
||||||
|
|
||||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
|
||||||
- Use a name that includes the commit, for example:
|
|
||||||
`keepassgo-<shortsha>-apk.zip`
|
|
||||||
|
|
||||||
### 5. Upload To Nextcloud
|
|
||||||
|
|
||||||
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
|
||||||
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
|
||||||
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
|
||||||
- Ensure that directory exists with mode `700`.
|
|
||||||
- Create secret temp files with mode `600`.
|
|
||||||
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
|
||||||
|
|
||||||
### 6. Emulator Install And Launch
|
|
||||||
|
|
||||||
- Reuse the existing emulator session if one is already running.
|
|
||||||
- Install with replacement:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
adb install -r build/keepassgo.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
- Launch KeePassGO and confirm it is focused.
|
|
||||||
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
|
||||||
|
|
||||||
### 7. Open A Controlled Demo Vault
|
|
||||||
|
|
||||||
- Do not rely on the user's real vault for this step.
|
|
||||||
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
|
||||||
- Open it in the emulator before closing out `ship it`.
|
|
||||||
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
|
||||||
|
|
||||||
## Closeout Requirements
|
|
||||||
|
|
||||||
When reporting back after `ship it`, include:
|
|
||||||
- the commit that was shipped
|
|
||||||
- the installed Arch package version
|
|
||||||
- the APK path
|
|
||||||
- the uploaded ZIP URL
|
|
||||||
- confirmation that the emulator app was launched
|
|
||||||
- confirmation that the controlled demo vault was opened
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Keep this workflow specific to KeePassGO.
|
|
||||||
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
|
||||||
- Do not use `rm -rf`.
|
|
||||||
- Do not use `rm -f`.
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
- "release-*"
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GO_VERSION: "1.26.1"
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
|
|
||||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
|
||||||
DIST_DIR: dist
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-test:
|
|
||||||
runs-on: keepassgo-android
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{ env.GO_VERSION }}
|
|
||||||
|
|
||||||
- name: Install native build dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
zsh \
|
|
||||||
pkg-config \
|
|
||||||
libx11-dev \
|
|
||||||
libx11-xcb-dev \
|
|
||||||
libxkbcommon-dev \
|
|
||||||
libxkbcommon-x11-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libvulkan-dev \
|
|
||||||
libegl1-mesa-dev \
|
|
||||||
libxcursor-dev \
|
|
||||||
libxfixes-dev
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
state_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf -- "$state_dir"' EXIT
|
|
||||||
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
state_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf -- "$state_dir"' EXIT
|
|
||||||
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: lint-test
|
|
||||||
runs-on: keepassgo-android
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{ env.GO_VERSION }}
|
|
||||||
|
|
||||||
- name: Install native build dependencies
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
zsh \
|
|
||||||
pkg-config \
|
|
||||||
libx11-dev \
|
|
||||||
libx11-xcb-dev \
|
|
||||||
libxkbcommon-dev \
|
|
||||||
libxkbcommon-x11-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libvulkan-dev \
|
|
||||||
libegl1-mesa-dev \
|
|
||||||
libxcursor-dev \
|
|
||||||
libxfixes-dev
|
|
||||||
|
|
||||||
- name: Prepare dist directory
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "${DIST_DIR}"
|
|
||||||
|
|
||||||
- name: Build desktop binaries
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
app_version="$(git describe --tags --always --dirty)"
|
|
||||||
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
|
|
||||||
# Keep the CI matrix to targets this runner can build reproducibly.
|
|
||||||
for target in \
|
|
||||||
"linux amd64" \
|
|
||||||
"windows amd64" \
|
|
||||||
"windows arm64"
|
|
||||||
do
|
|
||||||
set -- ${target}
|
|
||||||
goos="$1"
|
|
||||||
goarch="$2"
|
|
||||||
ext=""
|
|
||||||
cgo_enabled=0
|
|
||||||
if [[ "${goos}" == "linux" ]]; then
|
|
||||||
cgo_enabled=1
|
|
||||||
fi
|
|
||||||
if [[ "${goos}" == "windows" ]]; then
|
|
||||||
ext=".exe"
|
|
||||||
fi
|
|
||||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
|
||||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build APK
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p build/ci-signing
|
|
||||||
signkey_path="$(pwd)/build/ci-signing/android-release.keystore"
|
|
||||||
signpass_path="$(pwd)/build/ci-signing/android-release.pass"
|
|
||||||
trap 'rm -f -- "$signkey_path" "$signpass_path"' EXIT
|
|
||||||
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
|
||||||
printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
|
|
||||||
export APP_VERSION="$(git describe --tags --always --dirty)"
|
|
||||||
make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
|
|
||||||
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
|
||||||
|
|
||||||
- name: Upload CI artifacts
|
|
||||||
uses: christopherhx/gitea-upload-artifact@v4
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --use-system-ca
|
|
||||||
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
|
||||||
with:
|
|
||||||
name: keepassgo-${{ gitea.sha }}
|
|
||||||
path: |
|
|
||||||
dist/keepassgo-linux-amd64
|
|
||||||
dist/keepassgo-windows-amd64.exe
|
|
||||||
dist/keepassgo-windows-arm64.exe
|
|
||||||
dist/keepassgo.apk
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Publish release artifacts
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
|
||||||
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
|
||||||
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python3 scripts/gitea_release.py \
|
|
||||||
--server "${GITEA_SERVER_URL}" \
|
|
||||||
--repo "${GITEA_REPOSITORY}" \
|
|
||||||
--token "${GITEA_TOKEN}" \
|
|
||||||
--tag "${GITEA_REF_NAME}" \
|
|
||||||
"${DIST_DIR}/keepassgo-linux-amd64" \
|
|
||||||
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
|
||||||
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
|
||||||
"${DIST_DIR}/keepassgo.apk"
|
|
||||||
+1
-10
@@ -1,10 +1 @@
|
|||||||
build/
|
gio-keepass-mock
|
||||||
*.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,19 +1,8 @@
|
|||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- gocyclo
|
|
||||||
- gosimple
|
- gosimple
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 15
|
|
||||||
|
|
||||||
issues:
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- gocyclo
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
|
|||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
- Use the installed Go skills whenever they materially apply to the current slice of work.
|
- Use the installed Go skills whenever they materially apply to the current slice of work.
|
||||||
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
|
|
||||||
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
|
|
||||||
- The available Go skills for this repository are:
|
- The available Go skills for this repository are:
|
||||||
`go-code-review`,
|
`go-code-review`,
|
||||||
`go-concurrency`,
|
`go-concurrency`,
|
||||||
@@ -95,20 +93,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||||
- The stacked phone layout is the current preferred phone direction.
|
- The stacked phone layout is the current preferred phone direction.
|
||||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||||
- Keep the product feeling like the same application on desktop, Android phone,
|
|
||||||
and Android tablet.
|
|
||||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
|
||||||
user's mental model of the workflow.
|
|
||||||
- Use the same action names, the same primary next steps, and the same workflow
|
|
||||||
order across platforms unless there is a hard platform constraint.
|
|
||||||
- Treat workflow prominence and reachability as product behavior, not visual
|
|
||||||
polish. A feature is not parity-complete if it technically exists but is
|
|
||||||
harder to discover or reach on one platform.
|
|
||||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
|
||||||
than platform-specific workflow branches.
|
|
||||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
|
||||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
|
||||||
and consistent with the product theme.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -118,24 +102,9 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||||
- Treat the vault model as local-first across all platforms:
|
|
||||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
|
||||||
that local vault rather than replacing it as the primary object.
|
|
||||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
|
||||||
Android tablet:
|
|
||||||
shared remote configuration belongs in the vault,
|
|
||||||
cross-platform workflow stays the same,
|
|
||||||
and only Android's initial KDBX share/import transport may differ.
|
|
||||||
- Do not persist remote credentials in plaintext app-local state.
|
|
||||||
Keep only non-secret binding metadata outside the vault.
|
|
||||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
|
||||||
design:
|
|
||||||
opening or creating the local vault is the main workflow,
|
|
||||||
and remote setup, remote settings, and remote use attach to that vault.
|
|
||||||
|
|
||||||
## Delivery Discipline
|
## Delivery Discipline
|
||||||
|
|
||||||
- Treat bug fixes as the highest-priority items in `TODO.md`.
|
|
||||||
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
|
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
|
||||||
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
||||||
- Continue iterating in test-first slices:
|
- Continue iterating in test-first slices:
|
||||||
@@ -143,14 +112,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
implement the minimum code to satisfy them,
|
implement the minimum code to satisfy them,
|
||||||
verify with `go test ./...` and relevant lint checks,
|
verify with `go test ./...` and relevant lint checks,
|
||||||
and commit each completed behavior.
|
and commit each completed behavior.
|
||||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
|
||||||
just feature or label parity.
|
|
||||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
|
||||||
assert the same conceptual next step across desktop, phone, and tablet
|
|
||||||
layouts.
|
|
||||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
|
||||||
emulator or device for the exact flow being changed. Do not count “the same
|
|
||||||
buttons exist somewhere on screen” as sufficient validation.
|
|
||||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||||
- If blocked, state the blocker concretely and stop only at that point.
|
- If blocked, state the blocker concretely and stop only at that point.
|
||||||
|
|
||||||
@@ -159,39 +120,9 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Plan for direct KDBX support.
|
- Plan for direct KDBX support.
|
||||||
- Plan for direct WebDAV-based workflows.
|
- Plan for direct WebDAV-based workflows.
|
||||||
- Avoid adding npm-based or browser-stack dependencies.
|
- Avoid adding npm-based or browser-stack dependencies.
|
||||||
- Keep remote configuration and synchronization local-first:
|
|
||||||
the app should maintain a live local KDBX cache even when using a remote
|
|
||||||
store, so remote outage does not eliminate vault access.
|
|
||||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
|
||||||
storage.
|
|
||||||
- On Android, use system picker/share mechanisms for local vault import/export
|
|
||||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
||||||
- Keep `golangci-lint` passing.
|
- Keep `golangci-lint` passing.
|
||||||
- Keep `go test ./...` passing.
|
- Keep `go test ./...` passing.
|
||||||
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
|
||||||
- Keep the Android build requirements aligned with the known working setup:
|
|
||||||
`ANDROID_SDK_ROOT=/opt/android-sdk`,
|
|
||||||
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
|
|
||||||
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
|
||||||
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
|
|
||||||
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
|
|
||||||
- Remember the known Android runtime regression:
|
|
||||||
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
|
|
||||||
- When validating an APK in the emulator, prefer the known KeePassGO setup:
|
|
||||||
AVD `KeepassGoAPI35`,
|
|
||||||
package `org.julianfamily.keepassgo`,
|
|
||||||
activity `org.gioui.GioActivity`.
|
|
||||||
- Do not run emulator/manual APK tests against the user’s real persisted app state.
|
|
||||||
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the 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.
|
- Do not commit generated binaries.
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# Android Build
|
|
||||||
|
|
||||||
Build the APK with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make apk
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the release-signed APK with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make apk-release
|
|
||||||
```
|
|
||||||
|
|
||||||
`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
|
|
||||||
If the host does not have a working Java 25 install, it falls back to the
|
|
||||||
repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
|
|
||||||
with Java 25.
|
|
||||||
|
|
||||||
`make apk` remains a developer build path and may use Gio's default debug or
|
|
||||||
ephemeral signing behavior if no explicit signing key is provided.
|
|
||||||
`make apk-release` is the production-signing path and fails unless a dedicated
|
|
||||||
release keystore and password file are present.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
|
|
||||||
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
|
|
||||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
|
||||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
|
||||||
- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`.
|
|
||||||
- `APP_ID` overrides the Android application id.
|
|
||||||
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
|
||||||
- `APK_OUT` overrides the output path.
|
|
||||||
- `APK_VERSION` overrides the packaged app version.
|
|
||||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
|
||||||
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
|
|
||||||
- `SIGNPASS_FILE` provides the signing password by file instead of a command-line argument.
|
|
||||||
- `RELEASE_SIGNKEY` overrides the release keystore path used by `make apk-release`.
|
|
||||||
- `RELEASE_SIGNPASS_FILE` overrides the password file path used by `make apk-release`.
|
|
||||||
|
|
||||||
Default release-signing paths:
|
|
||||||
|
|
||||||
- `~/.config/keepassgo/android-release.keystore`
|
|
||||||
- `~/.config/keepassgo/android-release.pass`
|
|
||||||
|
|
||||||
Installed machine prerequisites expected by this repo:
|
|
||||||
|
|
||||||
- `android-sdk-cmdline-tools-latest`
|
|
||||||
- `android-sdk-build-tools`
|
|
||||||
- `android-platform-35`
|
|
||||||
- `android-sdk-platform-tools`
|
|
||||||
- a working Java 25 JDK install for `make apk-local`, or Docker for `make apk`
|
|
||||||
|
|
||||||
The repo tracks `gogio` as a Go tool, and the local build runs through:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go tool gogio -target android ./cmd/keepassgo ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The release target wraps `make apk` and injects explicit signing credentials so
|
|
||||||
local release builds and CI use the same stable key without echoing the release
|
|
||||||
password in build logs.
|
|
||||||
|
|
||||||
The Android build uses the branded icon asset at:
|
|
||||||
|
|
||||||
- `internal/assets/keepassgo-icon.png`
|
|
||||||
|
|
||||||
Note:
|
|
||||||
|
|
||||||
- KeePassGO's documented Android build uses Java 25 locally.
|
|
||||||
- If that host setup is unavailable, `make apk` falls back to the Docker image
|
|
||||||
so the build still runs under Java 25 instead of encoding a newer host JDK as
|
|
||||||
a requirement.
|
|
||||||
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
|
|
||||||
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
|
|
||||||
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
|
|
||||||
pipeline. KeePassGO is pinned to the working Gio line until that regression is
|
|
||||||
understood upstream.
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
ANDROID_SDK_ROOT ?= /opt/android-sdk
|
|
||||||
ANDROID_NDK_ROOT ?= /opt/android-ndk
|
|
||||||
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
|
|
||||||
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
|
|
||||||
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
|
|
||||||
APP_ID ?= org.julianfamily.keepassgo
|
|
||||||
APK_OUT ?= build/keepassgo.apk
|
|
||||||
APK_VERSION ?= 0.1.0.1
|
|
||||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
|
||||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
|
||||||
ANDROID_MIN_SDK ?= 28
|
|
||||||
ANDROID_TARGET_SDK ?= 35
|
|
||||||
SIGNKEY ?=
|
|
||||||
SIGNPASS ?=
|
|
||||||
SIGNPASS_FILE ?=
|
|
||||||
RELEASE_SIGNKEY ?= $(HOME)/.config/keepassgo/android-release.keystore
|
|
||||||
RELEASE_SIGNPASS_FILE ?= $(HOME)/.config/keepassgo/android-release.pass
|
|
||||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
|
||||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
|
||||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
|
||||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
|
||||||
ARCH_REPO_DIR ?= $(CURDIR)
|
|
||||||
|
|
||||||
GOGIO_SIGN_FLAGS :=
|
|
||||||
ifneq ($(strip $(SIGNKEY)),)
|
|
||||||
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
|
||||||
endif
|
|
||||||
ifneq ($(strip $(SIGNPASS)),)
|
|
||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
|
||||||
endif
|
|
||||||
|
|
||||||
CONTAINER_SIGNKEY_MOUNT :=
|
|
||||||
CONTAINER_SIGNPASSFILE_MOUNT :=
|
|
||||||
CONTAINER_SIGN_ARGS :=
|
|
||||||
ifneq ($(strip $(SIGNKEY)),)
|
|
||||||
CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro"
|
|
||||||
CONTAINER_SIGN_ARGS += SIGNKEY="$(abspath $(SIGNKEY))"
|
|
||||||
endif
|
|
||||||
ifneq ($(strip $(SIGNPASS)),)
|
|
||||||
CONTAINER_SIGN_ARGS += SIGNPASS="$(SIGNPASS)"
|
|
||||||
endif
|
|
||||||
ifneq ($(strip $(SIGNPASS_FILE)),)
|
|
||||||
CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(abspath $(SIGNPASS_FILE))):ro"
|
|
||||||
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate
|
|
||||||
apk:
|
|
||||||
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
|
|
||||||
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
|
|
||||||
else \
|
|
||||||
echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \
|
|
||||||
$(MAKE) apk-container; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
apk-local: android/keepassgo-android.jar
|
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
|
||||||
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
|
|
||||||
@mkdir -p "$(dir $(APK_OUT))"
|
|
||||||
@set -eu; \
|
|
||||||
if [ -n "$(SIGNPASS_FILE)" ]; then \
|
|
||||||
test -f "$(SIGNPASS_FILE)" || { echo "SIGNPASS_FILE does not exist: $(SIGNPASS_FILE)"; exit 1; }; \
|
|
||||||
export GOGIO_SIGNPASS="$$(tr -d '\r\n' < "$(SIGNPASS_FILE)")"; \
|
|
||||||
test -n "$$GOGIO_SIGNPASS" || { echo "SIGNPASS_FILE is empty: $(SIGNPASS_FILE)"; exit 1; }; \
|
|
||||||
fi; \
|
|
||||||
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
|
|
||||||
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
|
||||||
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
|
||||||
JAVA_HOME="$(JAVA_HOME)" \
|
|
||||||
go tool gogio -target android \
|
|
||||||
-buildmode exe \
|
|
||||||
-appid $(APP_ID) \
|
|
||||||
-ldflags "$(GO_LDFLAGS)" \
|
|
||||||
$(GOGIO_SIGN_FLAGS) \
|
|
||||||
-o $(APK_OUT) \
|
|
||||||
-version $(APK_VERSION) \
|
|
||||||
-minsdk $(ANDROID_MIN_SDK) \
|
|
||||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
|
||||||
-icon internal/assets/keepassgo-icon.png \
|
|
||||||
./cmd/keepassgo
|
|
||||||
|
|
||||||
apk-release:
|
|
||||||
@test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
|
|
||||||
@test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
|
|
||||||
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))"
|
|
||||||
|
|
||||||
apk-container: apk-container-image
|
|
||||||
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
|
||||||
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
|
||||||
docker run --rm \
|
|
||||||
-u "$$(id -u):$$(id -g)" \
|
|
||||||
-v "$(CURDIR):$(CURDIR)" \
|
|
||||||
-w "$(CURDIR)" \
|
|
||||||
-v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \
|
|
||||||
-v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \
|
|
||||||
$(CONTAINER_SIGNKEY_MOUNT) \
|
|
||||||
$(CONTAINER_SIGNPASSFILE_MOUNT) \
|
|
||||||
-e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
|
|
||||||
-e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
|
|
||||||
-e JAVA_HOME=/opt/java/openjdk \
|
|
||||||
$(APK_BUILD_IMAGE) \
|
|
||||||
make apk-local JAVA_HOME=/opt/java/openjdk $(CONTAINER_SIGN_ARGS)
|
|
||||||
|
|
||||||
apk-container-image:
|
|
||||||
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; }
|
|
||||||
docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk
|
|
||||||
|
|
||||||
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
|
||||||
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
|
||||||
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
|
|
||||||
@mkdir -p android
|
|
||||||
@sh -ec 'tmpdir=$$(mktemp -d); \
|
|
||||||
trap "rm -rf $$tmpdir" EXIT; \
|
|
||||||
"$(JAVA_HOME)/bin/javac" \
|
|
||||||
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
|
|
||||||
-d "$$tmpdir" \
|
|
||||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
|
||||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
|
||||||
|
|
||||||
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|
||||||
@mkdir -p "$(ARCH_PKG_DIR)"
|
|
||||||
@sed \
|
|
||||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
|
||||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
|
||||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
|
||||||
|
|
||||||
browser-bridge:
|
|
||||||
go build ./cmd/keepassgo-browser-bridge
|
|
||||||
|
|
||||||
browser-extension-validate:
|
|
||||||
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
|
|
||||||
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
|
|
||||||
@command -v openssl >/dev/null 2>&1 || { echo "openssl is required"; exit 1; }
|
|
||||||
xvfb-run -a python scripts/validate_browser_extension.py $(if $(BROWSER),--browser $(BROWSER),)
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# KeePassGO
|
# KeePassGO
|
||||||
|
|
||||||
KeePassGO is a Go-based KeePass-compatible password manager targeting desktop first, with future Android support.
|
KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support.
|
||||||
|
|
||||||
## Current Capabilities
|
## Current Capabilities
|
||||||
|
|
||||||
- KDBX load and save
|
- KDBX load and save
|
||||||
- password-only, key-file-only, and composite master-key flows through the desktop product UI
|
- password, key-file, and composite master-key support in the storage layer
|
||||||
- master-key changes for existing vault sessions
|
|
||||||
- WebDAV-backed open and save support in the session layer
|
- WebDAV-backed open and save support in the session layer
|
||||||
- password generation profiles
|
- password generation profiles
|
||||||
- gRPC integration surface for trusted automation
|
- gRPC integration surface for trusted automation
|
||||||
@@ -38,86 +37,28 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
|||||||
Desktop build:
|
Desktop build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./cmd/keepassgo
|
go build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
|
||||||
You can override the version shown in KeePassGO with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Arch Linux Package
|
|
||||||
|
|
||||||
An AUR-style package definition for the Linux desktop client lives under:
|
|
||||||
|
|
||||||
- `packaging/archlinux/keepassgo-git/`
|
|
||||||
|
|
||||||
From that directory you can build and install it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
makepkg -si
|
|
||||||
```
|
|
||||||
|
|
||||||
The package installs:
|
|
||||||
|
|
||||||
- `/usr/bin/keepassgo`
|
|
||||||
- `/usr/bin/keepassgo-browser-bridge`
|
|
||||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
|
||||||
- application icons under the hicolor theme
|
|
||||||
|
|
||||||
## Android Packaging
|
## Android Packaging
|
||||||
|
|
||||||
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
||||||
|
|
||||||
The repo now has automated tests for the packaging contract:
|
|
||||||
- default APK build arguments
|
|
||||||
- required Android SDK / NDK / JDK layout checks
|
|
||||||
|
|
||||||
Those are covered by normal test runs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get -tool gioui.org/cmd/gogio@latest
|
go install gioui.org/cmd/gogio@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Package:
|
Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make apk
|
gogio -target android .
|
||||||
```
|
```
|
||||||
|
|
||||||
`make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
|
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||||
available, it falls back to the repo-managed Docker build image, which also
|
|
||||||
uses Java 25. You still need the Android SDK and NDK installed and configured
|
|
||||||
for real device or release packaging.
|
|
||||||
|
|
||||||
Release package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make apk-release
|
|
||||||
```
|
|
||||||
|
|
||||||
`make apk-release` is the production-signing path. It requires a dedicated
|
|
||||||
release keystore at `~/.config/keepassgo/android-release.keystore` and a
|
|
||||||
password file at `~/.config/keepassgo/android-release.pass`, unless you
|
|
||||||
override `RELEASE_SIGNKEY` and `RELEASE_SIGNPASS_FILE`.
|
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
|
|
||||||
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||||
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
||||||
|
|
||||||
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
|
|
||||||
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
|
|
||||||
|
|
||||||
## Browser Extension
|
|
||||||
|
|
||||||
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
|
|
||||||
See [`docs/browser-extension.md`](./docs/browser-extension.md).
|
|
||||||
|
|||||||
@@ -6,137 +6,6 @@ These segments are intended to be independently executable wherever possible.
|
|||||||
Each segment has its own local exit criteria.
|
Each segment has its own local exit criteria.
|
||||||
The product is not complete until the global exit criteria at the end of this file are also met.
|
The product is not complete until the global exit criteria at the end of this file are also met.
|
||||||
|
|
||||||
## UI Review Follow-Ups
|
|
||||||
|
|
||||||
These items came from a hands-on emulator and desktop walkthrough.
|
|
||||||
They should be treated as usability work, not just polish.
|
|
||||||
|
|
||||||
### Cross-Platform Workflow Parity
|
|
||||||
|
|
||||||
These items are required to keep desktop, Android phone, and Android tablet
|
|
||||||
feeling like the same application rather than three related UIs.
|
|
||||||
|
|
||||||
- Workflow parity:
|
|
||||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
|
||||||
sync, browse entries, and edit entries.
|
|
||||||
- Workflow parity:
|
|
||||||
ensure desktop, phone, and tablet use the same action names and the same
|
|
||||||
primary next steps for those workflows.
|
|
||||||
- Workflow parity:
|
|
||||||
remove or reduce platform-specific workflow exceptions where the same user
|
|
||||||
intent currently takes a different route on different form factors.
|
|
||||||
- Testing:
|
|
||||||
add cross-mode behavior tests that assert workflow order and action
|
|
||||||
prominence, not just label presence.
|
|
||||||
- Testing:
|
|
||||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
|
||||||
action on desktop, phone, and tablet layouts.
|
|
||||||
- Testing:
|
|
||||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
|
||||||
removal are reachable from the same primary affordance family across modes.
|
|
||||||
- Android verification:
|
|
||||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
|
||||||
including with the on-screen keyboard visible.
|
|
||||||
- Android verification:
|
|
||||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
|
||||||
failure, not as acceptable platform variation.
|
|
||||||
|
|
||||||
### Primary Workflow Changes
|
|
||||||
|
|
||||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
|
||||||
|
|
||||||
- Browser extension:
|
|
||||||
- Local open flow:
|
|
||||||
make the start screen primarily about opening a vault, not configuring one.
|
|
||||||
- Local open flow:
|
|
||||||
keep recent vault selection visually obvious and clearly tappable.
|
|
||||||
- Local open flow:
|
|
||||||
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
|
|
||||||
- Local open flow:
|
|
||||||
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
|
|
||||||
- Local open flow:
|
|
||||||
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
|
|
||||||
- Remote open flow:
|
|
||||||
break the remote form into clearer sections such as `Location` and `Authentication`.
|
|
||||||
- Remote open flow:
|
|
||||||
make recent remote connections easier to scan with a friendlier label than raw URL and path.
|
|
||||||
- Locked screen:
|
|
||||||
show clear vault identity and target summary so the user knows what is being unlocked.
|
|
||||||
- Entries screen:
|
|
||||||
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
|
|
||||||
- Entries screen:
|
|
||||||
make breadcrumbs compress more aggressively on phone.
|
|
||||||
- Entries screen:
|
|
||||||
improve entry-row hierarchy and selected-state contrast.
|
|
||||||
- Entries screen:
|
|
||||||
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
|
|
||||||
- Group navigation:
|
|
||||||
make the distinction between root, current group, and child groups more obvious.
|
|
||||||
- Group navigation:
|
|
||||||
separate navigation controls from group-management controls more clearly.
|
|
||||||
- Entry detail:
|
|
||||||
tighten field spacing and reduce unnecessary whitespace.
|
|
||||||
- Entry detail:
|
|
||||||
group password reveal and copy actions more clearly.
|
|
||||||
- Entry detail:
|
|
||||||
make attachments more visible and actionable.
|
|
||||||
- Entry edit:
|
|
||||||
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
|
|
||||||
- Entry edit:
|
|
||||||
make add/remove affordances for custom fields more visually obvious.
|
|
||||||
- Entry edit:
|
|
||||||
make generated-password draft state more explicit before save.
|
|
||||||
- Recycle bin:
|
|
||||||
make it visually distinct from normal entry browsing.
|
|
||||||
- API tokens:
|
|
||||||
give token list, token detail, and policy editing a clearer dedicated management surface.
|
|
||||||
- API tokens:
|
|
||||||
make policy rows easier to scan by separating effect, operation, and resource visually.
|
|
||||||
- API audit:
|
|
||||||
improve empty-state guidance and provide quick filtering by token, decision, and operation.
|
|
||||||
- Synchronize:
|
|
||||||
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
|
|
||||||
- Synchronize:
|
|
||||||
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
|
|
||||||
- Synchronize:
|
|
||||||
define exact local-versus-remote merge semantics for cases where both sides changed, and make the user-facing action names describe the real behavior instead of ambiguous `push`/`pull` labels if those actions perform two-way reconciliation.
|
|
||||||
- Synchronize:
|
|
||||||
choose sync wording and defaults that maximize user comprehension and safety, especially around merge, overwrite, conflict, and retry behavior.
|
|
||||||
- Phone layout:
|
|
||||||
continue reducing header and control density so content appears sooner.
|
|
||||||
- Mobile reliability:
|
|
||||||
fix Android local-open ANR behavior before deeper mobile polish.
|
|
||||||
- Autofill UX:
|
|
||||||
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
|
|
||||||
|
|
||||||
### Settings Gear Candidates
|
|
||||||
|
|
||||||
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
|
|
||||||
|
|
||||||
- Vault security:
|
|
||||||
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
|
|
||||||
- Vault security:
|
|
||||||
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
|
|
||||||
- Remote preferences:
|
|
||||||
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
|
|
||||||
- UI preferences:
|
|
||||||
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
|
|
||||||
- Autofill behavior:
|
|
||||||
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
|
|
||||||
- Sync defaults:
|
|
||||||
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
|
|
||||||
- Notification preferences:
|
|
||||||
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
|
|
||||||
- Accessibility preferences:
|
|
||||||
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
|
|
||||||
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
|
||||||
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
|
|
||||||
- Phone and desktop layouts both present a clear information hierarchy.
|
|
||||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
|
||||||
|
|
||||||
### Segment 1: Application State Ownership
|
### Segment 1: Application State Ownership
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -205,6 +74,19 @@ Exit criteria:
|
|||||||
- Validation and visible error states exist for missing or invalid key material.
|
- Validation and visible error states exist for missing or invalid key material.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
|
### Segment 5: KDBX Security Settings Preservation
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Preserve supported cipher and KDF settings when reopening and saving.
|
||||||
|
- Surface relevant settings in product-facing docs or UI where appropriate.
|
||||||
|
- Document unsupported settings explicitly.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Reopen-and-save cycles preserve supported KDBX security settings.
|
||||||
|
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
|
||||||
|
- Tests cover settings preservation across save cycles.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 6: Entry CRUD UI
|
### Segment 6: Entry CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -378,21 +260,6 @@ Exit criteria:
|
|||||||
- Focus and accessibility states are visible and intentional.
|
- Focus and accessibility states are visible and intentional.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 21: Accessibility Fill Generalization
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Extend Android accessibility-based fill beyond the current Chrome demo path.
|
|
||||||
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
|
|
||||||
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
|
|
||||||
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
|
|
||||||
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
|
|
||||||
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
|
|
||||||
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 17: UI Completion And Error Surfaces
|
### Segment 17: UI Completion And Error Surfaces
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -410,6 +277,33 @@ Exit criteria:
|
|||||||
- UI tests or controller-integrated tests cover these states.
|
- UI tests or controller-integrated tests cover these states.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
|
### Segment 18: Desktop Automation Resolution
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
|
||||||
|
- or explicitly finalize the design that secure gRPC supersedes auto-type.
|
||||||
|
- Keep the decision documented in-repo.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- The desktop automation requirement is explicitly resolved in code or docs.
|
||||||
|
- The chosen approach is documented in `docs/desktop-automation.md`.
|
||||||
|
- Any implemented behavior is tested.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
|
||||||
|
### Segment 19: Packaging And Runbook
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Keep the app runnable from source.
|
||||||
|
- Document desktop build and run steps.
|
||||||
|
- Document Android packaging with `gogio`.
|
||||||
|
- Add icon and metadata placeholders if missing.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- `README.md` is accurate for local build, run, and Android packaging guidance.
|
||||||
|
- Placeholder metadata exists where needed for packaging.
|
||||||
|
- The app still builds from the repo.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 20: Regression And Integration Coverage
|
### Segment 20: Regression And Integration Coverage
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -427,10 +321,11 @@ Exit criteria:
|
|||||||
|
|
||||||
Do not treat the product as complete until all of the following are true:
|
Do not treat the product as complete until all of the following are true:
|
||||||
|
|
||||||
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
|
- Segment 1 through Segment 20 are all complete.
|
||||||
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
||||||
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
||||||
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
||||||
|
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
|
||||||
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
||||||
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
||||||
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
||||||
@@ -440,7 +335,9 @@ Do not treat the product as complete until all of the following are true:
|
|||||||
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
||||||
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
||||||
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
||||||
|
- The desktop automation requirement is explicitly resolved.
|
||||||
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
||||||
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
||||||
|
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
- `go tool golangci-lint run ./...` passes.
|
- `go tool golangci-lint run ./...` passes.
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
<service
|
|
||||||
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="KeePassGO Autofill"
|
|
||||||
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.service.autofill.AutofillService" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.autofill"
|
|
||||||
android:resource="@xml/keepassgo_autofill_service" />
|
|
||||||
</service>
|
|
||||||
<service
|
|
||||||
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="KeePassGO Accessibility Fill"
|
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.accessibilityservice"
|
|
||||||
android:resource="@xml/keepassgo_accessibility_service" />
|
|
||||||
</service>
|
|
||||||
<activity
|
|
||||||
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:label="Search KeePassGO" />
|
|
||||||
<provider
|
|
||||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
|
||||||
android:authorities="org.julianfamily.keepassgo.share"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true" />
|
|
||||||
<activity
|
|
||||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="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:mimeType="application/octet-stream" />
|
|
||||||
<data android:mimeType="application/x-keepass2" />
|
|
||||||
<data android:mimeType="application/vnd.keepass" />
|
|
||||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
|
||||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
|
|
||||||
android:accessibilityFeedbackType="feedbackGeneric"
|
|
||||||
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
|
|
||||||
android:canRetrieveWindowContent="true"
|
|
||||||
android:notificationTimeout="100"
|
|
||||||
android:settingsActivity="" />
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.Uri;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public final class AndroidShare {
|
|
||||||
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
|
||||||
|
|
||||||
private AndroidShare() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void shareVault(Context context, String path, String title) throws IOException {
|
|
||||||
File source = new File(path);
|
|
||||||
if (!source.isFile()) {
|
|
||||||
throw new IOException("vault file not found: " + path);
|
|
||||||
}
|
|
||||||
File shared = copyToSharedExport(context, source);
|
|
||||||
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
|
||||||
|
|
||||||
Intent send = new Intent(Intent.ACTION_SEND);
|
|
||||||
send.setType("application/x-keepass2");
|
|
||||||
send.putExtra(Intent.EXTRA_STREAM, uri);
|
|
||||||
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
|
||||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
|
|
||||||
Intent chooser = Intent.createChooser(send, "Share vault");
|
|
||||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
context.startActivity(chooser);
|
|
||||||
}
|
|
||||||
|
|
||||||
static File sharedDirectory(Context context) {
|
|
||||||
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static File copyToSharedExport(Context context, File source) throws IOException {
|
|
||||||
File dir = sharedDirectory(context);
|
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
|
||||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
|
||||||
}
|
|
||||||
File target = new File(dir, sanitizeFilename(source.getName()));
|
|
||||||
try (FileInputStream in = new FileInputStream(source);
|
|
||||||
FileOutputStream out = new FileOutputStream(target, false)) {
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int count;
|
|
||||||
while ((count = in.read(buffer)) >= 0) {
|
|
||||||
out.write(buffer, 0, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sanitizeFilename(String name) {
|
|
||||||
String trimmed = name == null ? "" : name.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
return "shared-vault.kdbx";
|
|
||||||
}
|
|
||||||
if (trimmed.endsWith(".kdbx")) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return trimmed + ".kdbx";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sanitizeTitle(String title, String fallbackName) {
|
|
||||||
String trimmed = title == null ? "" : title.trim();
|
|
||||||
if (!trimmed.isEmpty()) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
|
||||||
if (!fallback.isEmpty()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return DEFAULT_TITLE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.util.JsonReader;
|
|
||||||
import android.util.JsonWriter;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStreamWriter;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
final class AutofillBindingStore {
|
|
||||||
private static final String TAG = "KeePassGOAutofill";
|
|
||||||
|
|
||||||
private AutofillBindingStore() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static String entryIDForTarget(Context context, String rawTarget) {
|
|
||||||
Bindings bindings = read(context);
|
|
||||||
return bindings.apps.getOrDefault(normalize(rawTarget), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
static void rememberBinding(Context context, String rawTarget, String entryID) {
|
|
||||||
String target = normalize(rawTarget);
|
|
||||||
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Bindings bindings = read(context);
|
|
||||||
bindings.updatedAt = Instant.now().toString();
|
|
||||||
bindings.apps.put(target, entryID.trim());
|
|
||||||
write(context, bindings);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Bindings read(Context context) {
|
|
||||||
File path = path(context);
|
|
||||||
if (!path.isFile()) {
|
|
||||||
return new Bindings();
|
|
||||||
}
|
|
||||||
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
|
|
||||||
Bindings bindings = new Bindings();
|
|
||||||
reader.beginObject();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
String name = reader.nextName();
|
|
||||||
if ("updatedAt".equals(name)) {
|
|
||||||
bindings.updatedAt = nextString(reader);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ("apps".equals(name)) {
|
|
||||||
reader.beginObject();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
|
|
||||||
}
|
|
||||||
reader.endObject();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
reader.skipValue();
|
|
||||||
}
|
|
||||||
reader.endObject();
|
|
||||||
return bindings;
|
|
||||||
} catch (IOException err) {
|
|
||||||
Log.e(TAG, "failed to read autofill bindings", err);
|
|
||||||
return new Bindings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void write(Context context, Bindings bindings) {
|
|
||||||
File path = path(context);
|
|
||||||
File parent = path.getParentFile();
|
|
||||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
|
||||||
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
|
|
||||||
writer.setIndent(" ");
|
|
||||||
writer.beginObject();
|
|
||||||
writer.name("updatedAt").value(bindings.updatedAt);
|
|
||||||
writer.name("apps");
|
|
||||||
writer.beginObject();
|
|
||||||
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
|
|
||||||
writer.name(entry.getKey()).value(entry.getValue());
|
|
||||||
}
|
|
||||||
writer.endObject();
|
|
||||||
writer.endObject();
|
|
||||||
} catch (IOException err) {
|
|
||||||
Log.e(TAG, "failed to write autofill bindings", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nextString(JsonReader reader) throws IOException {
|
|
||||||
if (reader.peek() == android.util.JsonToken.NULL) {
|
|
||||||
reader.nextNull();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return reader.nextString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static File path(Context context) {
|
|
||||||
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalize(String rawTarget) {
|
|
||||||
return rawTarget == null ? "" : rawTarget.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Bindings {
|
|
||||||
String updatedAt = "";
|
|
||||||
final Map<String, String> apps = new LinkedHashMap<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.util.JsonReader;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
final class AutofillCacheStore {
|
|
||||||
private static final String TAG = "KeePassGOAutofill";
|
|
||||||
|
|
||||||
private AutofillCacheStore() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static Entry findBestMatch(Context context, String webDomain) {
|
|
||||||
List<Entry> entries = readEntries(context);
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
NormalizedTarget target = normalizeURL(webDomain);
|
|
||||||
if (target.host.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
List<Entry> exactHost = new ArrayList<>();
|
|
||||||
List<Entry> parentHost = new ArrayList<>();
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
if (entryMatchesHost(entry, target.host)) {
|
|
||||||
exactHost.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entryMatchesParentHost(entry, target.host)) {
|
|
||||||
parentHost.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Entry matched = chooseEntry(target, exactHost);
|
|
||||||
if (matched != null) {
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
return chooseEntry(target, parentHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Entry findByID(Context context, String entryID) {
|
|
||||||
if (entryID == null || entryID.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (Entry entry : readEntries(context)) {
|
|
||||||
if (entryID.equals(entry.id)) {
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<Entry> chooserCandidates(Context context, String rawTarget) {
|
|
||||||
List<Entry> entries = readEntries(context);
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
Entry direct = findBestMatch(context, rawTarget);
|
|
||||||
if (direct != null) {
|
|
||||||
List<Entry> resolved = new ArrayList<>();
|
|
||||||
resolved.add(direct);
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
entries.sort(Comparator
|
|
||||||
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
|
|
||||||
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
|
|
||||||
.thenComparing(entry -> entry.id));
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entry> readEntries(Context context) {
|
|
||||||
File cacheFile = findCacheFile(context);
|
|
||||||
if (cacheFile == null) {
|
|
||||||
Log.i(TAG, "autofill cache file not found");
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return readEntries(cacheFile);
|
|
||||||
} catch (IOException err) {
|
|
||||||
Log.e(TAG, "failed to read autofill cache", err);
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static File findCacheFile(Context context) {
|
|
||||||
List<File> candidates = new ArrayList<>();
|
|
||||||
File filesDir = context.getFilesDir();
|
|
||||||
if (filesDir != null) {
|
|
||||||
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
|
|
||||||
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
|
|
||||||
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
|
|
||||||
}
|
|
||||||
File baseDir = context.getDataDir();
|
|
||||||
if (baseDir != null) {
|
|
||||||
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
|
|
||||||
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
|
|
||||||
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
|
|
||||||
}
|
|
||||||
for (File candidate : candidates) {
|
|
||||||
if (candidate.isFile()) {
|
|
||||||
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.i(TAG, "no autofill cache file in " + candidates);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entry> readEntries(File cacheFile) throws IOException {
|
|
||||||
List<Entry> entries = new ArrayList<>();
|
|
||||||
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
|
|
||||||
reader.beginObject();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
String name = reader.nextName();
|
|
||||||
if ("entries".equals(name)) {
|
|
||||||
reader.beginArray();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
entries.add(readEntry(reader));
|
|
||||||
}
|
|
||||||
reader.endArray();
|
|
||||||
} else {
|
|
||||||
reader.skipValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.endObject();
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Entry readEntry(JsonReader reader) throws IOException {
|
|
||||||
String id = "";
|
|
||||||
String title = "";
|
|
||||||
String username = "";
|
|
||||||
String password = "";
|
|
||||||
String host = "";
|
|
||||||
String url = "";
|
|
||||||
List<String> targets = new ArrayList<>();
|
|
||||||
List<String> path = new ArrayList<>();
|
|
||||||
reader.beginObject();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
String name = reader.nextName();
|
|
||||||
switch (name) {
|
|
||||||
case "id":
|
|
||||||
id = nextString(reader);
|
|
||||||
break;
|
|
||||||
case "title":
|
|
||||||
title = nextString(reader);
|
|
||||||
break;
|
|
||||||
case "username":
|
|
||||||
username = nextString(reader);
|
|
||||||
break;
|
|
||||||
case "password":
|
|
||||||
password = nextString(reader);
|
|
||||||
break;
|
|
||||||
case "url":
|
|
||||||
url = nextString(reader);
|
|
||||||
break;
|
|
||||||
case "host":
|
|
||||||
host = normalizeHost(nextString(reader));
|
|
||||||
break;
|
|
||||||
case "targets":
|
|
||||||
reader.beginArray();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
targets.add(nextString(reader));
|
|
||||||
}
|
|
||||||
reader.endArray();
|
|
||||||
break;
|
|
||||||
case "path":
|
|
||||||
reader.beginArray();
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
path.add(nextString(reader));
|
|
||||||
}
|
|
||||||
reader.endArray();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
reader.skipValue();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.endObject();
|
|
||||||
return new Entry(id, title, username, password, host, url, targets, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nextString(JsonReader reader) throws IOException {
|
|
||||||
if (reader.peek() == android.util.JsonToken.NULL) {
|
|
||||||
reader.nextNull();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return reader.nextString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeHost(String raw) {
|
|
||||||
return normalizeURL(raw).host;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static NormalizedTarget normalizeURL(String raw) {
|
|
||||||
if (raw == null) {
|
|
||||||
return new NormalizedTarget("", "", "");
|
|
||||||
}
|
|
||||||
String value = raw.trim().toLowerCase(Locale.US);
|
|
||||||
if (value.startsWith("http://")) {
|
|
||||||
value = value.substring("http://".length());
|
|
||||||
} else if (value.startsWith("https://")) {
|
|
||||||
value = value.substring("https://".length());
|
|
||||||
}
|
|
||||||
int slash = value.indexOf('/');
|
|
||||||
if (slash >= 0) {
|
|
||||||
value = value.substring(0, slash);
|
|
||||||
}
|
|
||||||
int colon = value.indexOf(':');
|
|
||||||
if (colon >= 0) {
|
|
||||||
value = value.substring(0, colon);
|
|
||||||
}
|
|
||||||
String host = value;
|
|
||||||
String path = "/";
|
|
||||||
int schemeSep = raw.indexOf("://");
|
|
||||||
String original = raw.trim();
|
|
||||||
if (schemeSep < 0) {
|
|
||||||
original = "https://" + original;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
java.net.URI uri = java.net.URI.create(original);
|
|
||||||
if (uri.getHost() != null) {
|
|
||||||
host = uri.getHost().toLowerCase(Locale.US);
|
|
||||||
}
|
|
||||||
path = cleanPath(uri.getPath());
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
path = "/";
|
|
||||||
}
|
|
||||||
return new NormalizedTarget(host, path, host + path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String cleanPath(String raw) {
|
|
||||||
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
String value = raw.trim();
|
|
||||||
while (value.endsWith("/") && value.length() > 1) {
|
|
||||||
value = value.substring(0, value.length() - 1);
|
|
||||||
}
|
|
||||||
if (!value.startsWith("/")) {
|
|
||||||
value = "/" + value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (entries.size() == 1) {
|
|
||||||
return entries.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Entry> exact = new ArrayList<>();
|
|
||||||
List<Entry> prefix = new ArrayList<>();
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (Entry entry : entries) {
|
|
||||||
MatchQuality quality = bestTargetMatch(entry, target);
|
|
||||||
if (quality.exact) {
|
|
||||||
exact.add(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quality.prefixLength > bestPrefixLen) {
|
|
||||||
prefix.clear();
|
|
||||||
prefix.add(entry);
|
|
||||||
bestPrefixLen = quality.prefixLength;
|
|
||||||
} else if (quality.prefixLength == bestPrefixLen) {
|
|
||||||
prefix.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exact.size() == 1) {
|
|
||||||
return exact.get(0);
|
|
||||||
}
|
|
||||||
if (exact.size() > 1 || prefix.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix.size() == 1 ? prefix.get(0) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (target.host.equals(host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
|
||||||
for (NormalizedTarget target : entryTargets(entry)) {
|
|
||||||
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
|
||||||
List<String> rawTargets = entry.targets;
|
|
||||||
if (rawTargets.isEmpty()) {
|
|
||||||
rawTargets = new ArrayList<>();
|
|
||||||
rawTargets.add(entry.url);
|
|
||||||
}
|
|
||||||
List<NormalizedTarget> targets = new ArrayList<>();
|
|
||||||
for (String rawTarget : rawTargets) {
|
|
||||||
NormalizedTarget target = normalizeURL(rawTarget);
|
|
||||||
if (!target.host.isEmpty()) {
|
|
||||||
targets.add(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
|
||||||
int bestPrefixLen = -1;
|
|
||||||
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
|
||||||
if (entryTarget.url.equals(target.url)) {
|
|
||||||
return new MatchQuality(true, 0);
|
|
||||||
}
|
|
||||||
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
|
||||||
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new MatchQuality(false, bestPrefixLen);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final class Entry {
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String username;
|
|
||||||
final String password;
|
|
||||||
final String host;
|
|
||||||
final String url;
|
|
||||||
final List<String> targets;
|
|
||||||
final List<String> path;
|
|
||||||
|
|
||||||
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
|
|
||||||
this.id = id;
|
|
||||||
this.title = title;
|
|
||||||
this.username = username;
|
|
||||||
this.password = password;
|
|
||||||
this.host = host;
|
|
||||||
this.url = url;
|
|
||||||
this.targets = new ArrayList<>(targets);
|
|
||||||
this.path = new ArrayList<>(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class MatchQuality {
|
|
||||||
final boolean exact;
|
|
||||||
final int prefixLength;
|
|
||||||
|
|
||||||
MatchQuality(boolean exact, int prefixLength) {
|
|
||||||
this.exact = exact;
|
|
||||||
this.prefixLength = prefixLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class NormalizedTarget {
|
|
||||||
final String host;
|
|
||||||
final String path;
|
|
||||||
final String url;
|
|
||||||
|
|
||||||
NormalizedTarget(String host, String path, String url) {
|
|
||||||
this.host = host;
|
|
||||||
this.path = path;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.accessibilityservice.AccessibilityService;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.accessibility.AccessibilityEvent;
|
|
||||||
import android.view.accessibility.AccessibilityNodeInfo;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
|
||||||
private static final String TAG = "KeePassGOA11y";
|
|
||||||
|
|
||||||
private String lastFilledSignature = "";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAccessibilityEvent(AccessibilityEvent event) {
|
|
||||||
try {
|
|
||||||
AccessibilityNodeInfo root = getRootInActiveWindow();
|
|
||||||
if (root == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CharSequence packageName = root.getPackageName();
|
|
||||||
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChromeForm form = inspectChrome(root);
|
|
||||||
if (form == null || form.passwordField == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
|
||||||
if (entry == null) {
|
|
||||||
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
|
||||||
if (signature.equals(lastFilledSignature)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean filled = false;
|
|
||||||
if (form.usernameField != null) {
|
|
||||||
filled |= setNodeText(form.usernameField, entry.username);
|
|
||||||
}
|
|
||||||
filled |= setNodeText(form.passwordField, entry.password);
|
|
||||||
if (filled) {
|
|
||||||
lastFilledSignature = signature;
|
|
||||||
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
|
|
||||||
}
|
|
||||||
} catch (Exception err) {
|
|
||||||
Log.e(TAG, "accessibility fill failed", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onInterrupt() {
|
|
||||||
Log.i(TAG, "accessibility service interrupted");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
|
||||||
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
|
||||||
ChromeForm form = new ChromeForm();
|
|
||||||
walk(root, editables, form);
|
|
||||||
if (form.url.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (form.passwordField == null) {
|
|
||||||
for (AccessibilityNodeInfo node : editables) {
|
|
||||||
if (isPasswordNode(node)) {
|
|
||||||
form.passwordField = node;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (form.usernameField == null && form.passwordField != null) {
|
|
||||||
for (AccessibilityNodeInfo node : editables) {
|
|
||||||
if (node.equals(form.passwordField)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isUsernameNode(node)) {
|
|
||||||
form.usernameField = node;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (form.usernameField == null) {
|
|
||||||
for (AccessibilityNodeInfo node : editables) {
|
|
||||||
if (node.equals(form.passwordField)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
form.usernameField = node;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return form;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void walk(
|
|
||||||
AccessibilityNodeInfo node,
|
|
||||||
List<AccessibilityNodeInfo> editables,
|
|
||||||
ChromeForm form
|
|
||||||
) {
|
|
||||||
if (node == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CharSequence viewID = node.getViewIdResourceName();
|
|
||||||
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
|
||||||
CharSequence text = node.getText();
|
|
||||||
if (text != null) {
|
|
||||||
form.url = text.toString().trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.isEditable()) {
|
|
||||||
editables.add(node);
|
|
||||||
if (form.passwordField == null && isPasswordNode(node)) {
|
|
||||||
form.passwordField = node;
|
|
||||||
} else if (form.usernameField == null && isUsernameNode(node)) {
|
|
||||||
form.usernameField = node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = 0; i < node.getChildCount(); i++) {
|
|
||||||
AccessibilityNodeInfo child = node.getChild(i);
|
|
||||||
if (child != null) {
|
|
||||||
walk(child, editables, form);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
|
||||||
if (node.isPassword()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return nodeMatches(node, "password");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
|
|
||||||
if (isPasswordNode(node)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return nodeMatches(node, "username")
|
|
||||||
|| nodeMatches(node, "email")
|
|
||||||
|| nodeMatches(node, "login")
|
|
||||||
|| nodeMatches(node, "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
|
|
||||||
String lowerTerm = term.toLowerCase();
|
|
||||||
return containsLower(node.getHintText(), lowerTerm)
|
|
||||||
|| containsLower(node.getText(), lowerTerm)
|
|
||||||
|| containsLower(node.getContentDescription(), lowerTerm)
|
|
||||||
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|
|
||||||
|| containsLower(node.getPaneTitle(), lowerTerm);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsLower(CharSequence value, String term) {
|
|
||||||
return value != null && value.toString().toLowerCase().contains(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
|
|
||||||
if (node == null || !node.isEditable() || value == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Bundle args = new Bundle();
|
|
||||||
args.putCharSequence(
|
|
||||||
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
|
|
||||||
value
|
|
||||||
);
|
|
||||||
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String nodeKey(AccessibilityNodeInfo node) {
|
|
||||||
CharSequence viewID = node.getViewIdResourceName();
|
|
||||||
if (viewID != null) {
|
|
||||||
return viewID.toString();
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
return String.valueOf(node.getUniqueId());
|
|
||||||
}
|
|
||||||
CharSequence hint = node.getHintText();
|
|
||||||
if (hint != null) {
|
|
||||||
return hint.toString();
|
|
||||||
}
|
|
||||||
return String.valueOf(node.hashCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class ChromeForm {
|
|
||||||
String url = "";
|
|
||||||
AccessibilityNodeInfo usernameField;
|
|
||||||
AccessibilityNodeInfo passwordField;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.Editable;
|
|
||||||
import android.text.TextWatcher;
|
|
||||||
import android.util.TypedValue;
|
|
||||||
import android.view.Gravity;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.autofill.AutofillId;
|
|
||||||
import android.view.autofill.AutofillManager;
|
|
||||||
import android.view.autofill.AutofillValue;
|
|
||||||
import android.widget.AdapterView;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.EditText;
|
|
||||||
import android.widget.LinearLayout;
|
|
||||||
import android.widget.ListView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import android.service.autofill.Dataset;
|
|
||||||
|
|
||||||
public final class KeePassGOAutofillPickerActivity extends Activity {
|
|
||||||
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
|
|
||||||
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
|
|
||||||
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
|
|
||||||
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
|
|
||||||
|
|
||||||
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
|
|
||||||
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
|
|
||||||
private ArrayAdapter<String> adapter;
|
|
||||||
private String matchTarget = "";
|
|
||||||
private String packageName = "";
|
|
||||||
private AutofillId usernameID;
|
|
||||||
private AutofillId passwordID;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
Intent intent = getIntent();
|
|
||||||
matchTarget = intent.getStringExtra(EXTRA_TARGET);
|
|
||||||
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
|
|
||||||
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
|
|
||||||
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
|
|
||||||
|
|
||||||
LinearLayout root = new LinearLayout(this);
|
|
||||||
root.setOrientation(LinearLayout.VERTICAL);
|
|
||||||
int padding = dp(16);
|
|
||||||
root.setPadding(padding, padding, padding, padding);
|
|
||||||
|
|
||||||
TextView title = new TextView(this);
|
|
||||||
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
|
||||||
title.setGravity(Gravity.START);
|
|
||||||
title.setPadding(0, 0, 0, dp(8));
|
|
||||||
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
|
|
||||||
root.addView(title, new LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
));
|
|
||||||
|
|
||||||
EditText search = new EditText(this);
|
|
||||||
search.setHint("Search entries");
|
|
||||||
root.addView(search, new LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
));
|
|
||||||
|
|
||||||
allEntries.clear();
|
|
||||||
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
|
|
||||||
visibleEntries.clear();
|
|
||||||
visibleEntries.addAll(allEntries);
|
|
||||||
|
|
||||||
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
|
|
||||||
ListView list = new ListView(this);
|
|
||||||
list.setAdapter(adapter);
|
|
||||||
list.setOnItemClickListener(this::onEntrySelected);
|
|
||||||
|
|
||||||
TextView empty = new TextView(this);
|
|
||||||
empty.setPadding(0, dp(16), 0, 0);
|
|
||||||
empty.setText("No KeePassGO entries are available for autofill.");
|
|
||||||
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
|
|
||||||
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
|
|
||||||
root.addView(empty, new LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
));
|
|
||||||
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
listParams.weight = 1f;
|
|
||||||
listParams.topMargin = dp(12);
|
|
||||||
root.addView(list, listParams);
|
|
||||||
|
|
||||||
search.addTextChangedListener(new TextWatcher() {
|
|
||||||
@Override
|
|
||||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
|
||||||
filterEntries(s == null ? "" : s.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterTextChanged(Editable s) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setContentView(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void filterEntries(String rawQuery) {
|
|
||||||
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
|
|
||||||
visibleEntries.clear();
|
|
||||||
if (query.isEmpty()) {
|
|
||||||
visibleEntries.addAll(allEntries);
|
|
||||||
} else {
|
|
||||||
for (AutofillCacheStore.Entry entry : allEntries) {
|
|
||||||
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
|
|
||||||
visibleEntries.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
adapter.clear();
|
|
||||||
adapter.addAll(labelsFor(visibleEntries));
|
|
||||||
adapter.notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
|
|
||||||
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
|
|
||||||
setResult(Activity.RESULT_CANCELED);
|
|
||||||
finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
AutofillCacheStore.Entry entry = visibleEntries.get(position);
|
|
||||||
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
|
|
||||||
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Dataset.Builder dataset = new Dataset.Builder();
|
|
||||||
dataset.setId(entry.id);
|
|
||||||
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
|
|
||||||
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
|
|
||||||
}
|
|
||||||
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
|
|
||||||
|
|
||||||
Intent result = new Intent();
|
|
||||||
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
|
|
||||||
setResult(Activity.RESULT_OK, result);
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
|
|
||||||
List<String> labels = new ArrayList<>(entries.size());
|
|
||||||
for (AutofillCacheStore.Entry entry : entries) {
|
|
||||||
labels.add(entryLabel(entry));
|
|
||||||
}
|
|
||||||
return labels;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String entryLabel(AutofillCacheStore.Entry entry) {
|
|
||||||
StringBuilder label = new StringBuilder();
|
|
||||||
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
|
|
||||||
if (entry.username != null && !entry.username.trim().isEmpty()) {
|
|
||||||
label.append(" (").append(entry.username.trim()).append(")");
|
|
||||||
}
|
|
||||||
if (entry.path != null && !entry.path.isEmpty()) {
|
|
||||||
label.append(" • ").append(String.join(" / ", entry.path));
|
|
||||||
}
|
|
||||||
return label.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int dp(int value) {
|
|
||||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.app.assist.AssistStructure;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.CancellationSignal;
|
|
||||||
import android.service.autofill.AutofillService;
|
|
||||||
import android.service.autofill.Dataset;
|
|
||||||
import android.service.autofill.FillCallback;
|
|
||||||
import android.service.autofill.FillContext;
|
|
||||||
import android.service.autofill.FillRequest;
|
|
||||||
import android.service.autofill.FillResponse;
|
|
||||||
import android.service.autofill.SaveCallback;
|
|
||||||
import android.service.autofill.SaveRequest;
|
|
||||||
import android.text.InputType;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.autofill.AutofillId;
|
|
||||||
import android.view.autofill.AutofillValue;
|
|
||||||
import android.widget.RemoteViews;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class KeePassGOAutofillService extends AutofillService {
|
|
||||||
private static final String TAG = "KeePassGOAutofill";
|
|
||||||
private static final String APP_SCHEME = "androidapp://";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConnected() {
|
|
||||||
super.onConnected();
|
|
||||||
Log.i(TAG, "service connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisconnected() {
|
|
||||||
Log.i(TAG, "service disconnected");
|
|
||||||
super.onDisconnected();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFillRequest(
|
|
||||||
FillRequest request,
|
|
||||||
CancellationSignal cancellationSignal,
|
|
||||||
FillCallback callback
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "fill request flags=" + request.getFlags());
|
|
||||||
List<FillContext> contexts = request.getFillContexts();
|
|
||||||
if (contexts.isEmpty()) {
|
|
||||||
Log.i(TAG, "fill request had no contexts");
|
|
||||||
callback.onSuccess(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
|
|
||||||
ParsedFields fields = new ParsedFields();
|
|
||||||
ParsedTarget target = parseWindow(structure, fields);
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"parsed target=" + target.matchTarget
|
|
||||||
+ " package=" + target.packageName
|
|
||||||
+ " usernameId=" + fields.usernameId
|
|
||||||
+ " passwordId=" + fields.passwordId
|
|
||||||
);
|
|
||||||
if (fields.passwordId == null) {
|
|
||||||
Log.i(TAG, "no password field found");
|
|
||||||
callback.onSuccess(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
|
|
||||||
if (entry == null) {
|
|
||||||
FillResponse chooser = chooserResponse(target, fields);
|
|
||||||
if (chooser == null) {
|
|
||||||
Log.i(TAG, "no autofill cache match");
|
|
||||||
callback.onSuccess(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
|
|
||||||
callback.onSuccess(chooser);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
|
||||||
|
|
||||||
FillResponse response = directFillResponse(entry, fields);
|
|
||||||
Log.i(TAG, "returning dataset");
|
|
||||||
callback.onSuccess(response);
|
|
||||||
} catch (Exception err) {
|
|
||||||
Log.e(TAG, "fill request failed", err);
|
|
||||||
callback.onFailure(err.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
|
|
||||||
Log.i(TAG, "save request");
|
|
||||||
callback.onSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
|
|
||||||
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
|
|
||||||
if (!entryID.isEmpty()) {
|
|
||||||
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
|
|
||||||
if (bound != null) {
|
|
||||||
return bound;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AutofillCacheStore.findBestMatch(this, matchTarget);
|
|
||||||
}
|
|
||||||
|
|
||||||
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
|
||||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
|
||||||
presentation.setTextViewText(
|
|
||||||
android.R.id.text1,
|
|
||||||
entry.title + " (" + entry.username + ")"
|
|
||||||
);
|
|
||||||
|
|
||||||
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
|
||||||
dataset.setId(entry.id);
|
|
||||||
if (fields.usernameId != null) {
|
|
||||||
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
|
||||||
}
|
|
||||||
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
|
||||||
|
|
||||||
return new FillResponse.Builder()
|
|
||||||
.addDataset(dataset.build())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
|
|
||||||
if (fields.passwordId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
|
|
||||||
if (candidates.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
|
||||||
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
|
|
||||||
|
|
||||||
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
|
|
||||||
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
|
|
||||||
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
|
|
||||||
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
|
|
||||||
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
|
|
||||||
|
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
target.matchTarget.hashCode(),
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
|
|
||||||
);
|
|
||||||
|
|
||||||
AutofillId[] ids;
|
|
||||||
if (fields.usernameId != null) {
|
|
||||||
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
|
|
||||||
} else {
|
|
||||||
ids = new AutofillId[]{fields.passwordId};
|
|
||||||
}
|
|
||||||
return new FillResponse.Builder()
|
|
||||||
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
|
||||||
String domain = "";
|
|
||||||
final int windowCount = structure.getWindowNodeCount();
|
|
||||||
for (int i = 0; i < windowCount; i++) {
|
|
||||||
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
|
|
||||||
String next = parseNode(root, fields);
|
|
||||||
if (!next.isEmpty()) {
|
|
||||||
domain = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String packageName = "";
|
|
||||||
if (structure.getActivityComponent() != null) {
|
|
||||||
packageName = structure.getActivityComponent().getPackageName();
|
|
||||||
}
|
|
||||||
if (!domain.isEmpty()) {
|
|
||||||
return new ParsedTarget(domain, packageName);
|
|
||||||
}
|
|
||||||
if (packageName != null && !packageName.isEmpty()) {
|
|
||||||
return new ParsedTarget(APP_SCHEME + packageName, packageName);
|
|
||||||
}
|
|
||||||
return new ParsedTarget("", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
|
|
||||||
String domain = "";
|
|
||||||
if (node.getWebDomain() != null) {
|
|
||||||
domain = node.getWebDomain();
|
|
||||||
}
|
|
||||||
|
|
||||||
AutofillId id = node.getAutofillId();
|
|
||||||
if (id != null) {
|
|
||||||
if (fields.passwordId == null && isPasswordNode(node)) {
|
|
||||||
fields.passwordId = id;
|
|
||||||
} else if (fields.usernameId == null && isUsernameNode(node)) {
|
|
||||||
fields.usernameId = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < node.getChildCount(); i++) {
|
|
||||||
String childDomain = parseNode(node.getChildAt(i), fields);
|
|
||||||
if (!childDomain.isEmpty()) {
|
|
||||||
domain = childDomain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
|
|
||||||
int inputType = node.getInputType();
|
|
||||||
int variation = inputType & InputType.TYPE_MASK_VARIATION;
|
|
||||||
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
||||||
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
|
||||||
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return nodeMatches(node, "password");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
|
|
||||||
int autofillType = node.getAutofillType();
|
|
||||||
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isPasswordNode(node)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return nodeMatches(node, "username")
|
|
||||||
|| nodeMatches(node, "email")
|
|
||||||
|| nodeMatches(node, "login")
|
|
||||||
|| nodeMatches(node, "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
|
|
||||||
String lowerTerm = term.toLowerCase();
|
|
||||||
String[] hints = node.getAutofillHints();
|
|
||||||
if (hints != null) {
|
|
||||||
for (String hint : hints) {
|
|
||||||
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (containsLower(node.getHint(), lowerTerm)
|
|
||||||
|| containsLower(node.getIdEntry(), lowerTerm)
|
|
||||||
|| containsLower(node.getText(), lowerTerm)
|
|
||||||
|| containsLower(node.getContentDescription(), lowerTerm)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsLower(CharSequence value, String term) {
|
|
||||||
return value != null && value.toString().toLowerCase().contains(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class ParsedFields {
|
|
||||||
AutofillId usernameId;
|
|
||||||
AutofillId passwordId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class ParsedTarget {
|
|
||||||
final String matchTarget;
|
|
||||||
final String packageName;
|
|
||||||
|
|
||||||
ParsedTarget(String matchTarget, String packageName) {
|
|
||||||
this.matchTarget = matchTarget;
|
|
||||||
this.packageName = packageName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.ClipData;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
public final class SharedVaultImportActivity extends Activity {
|
|
||||||
private static final String TAG = "KeePassGOImport";
|
|
||||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle state) {
|
|
||||||
super.onCreate(state);
|
|
||||||
handleIntent(getIntent());
|
|
||||||
launchMainActivity();
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onNewIntent(Intent intent) {
|
|
||||||
super.onNewIntent(intent);
|
|
||||||
setIntent(intent);
|
|
||||||
handleIntent(intent);
|
|
||||||
launchMainActivity();
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
|
||||||
logIntent(intent);
|
|
||||||
Uri uri = resolveSharedUri(intent);
|
|
||||||
if (uri == null) {
|
|
||||||
Log.i(TAG, "no shared vault URI on intent");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
persistPendingImport(uri);
|
|
||||||
Log.i(TAG, "queued shared vault import from " + uri);
|
|
||||||
} catch (IOException | RuntimeException err) {
|
|
||||||
Log.e(TAG, "failed to queue shared vault import", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Uri resolveSharedUri(Intent intent) {
|
|
||||||
if (intent == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String action = intent.getAction();
|
|
||||||
if (Intent.ACTION_SEND.equals(action)) {
|
|
||||||
Uri extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
|
||||||
if (extraStream != null) {
|
|
||||||
return extraStream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
|
|
||||||
ArrayList<Uri> streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
|
||||||
if (streams != null && !streams.isEmpty()) {
|
|
||||||
return streams.get(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Intent.ACTION_VIEW.equals(action)) {
|
|
||||||
Uri data = intent.getData();
|
|
||||||
if (data != null) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClipData clipData = intent.getClipData();
|
|
||||||
if (clipData != null && clipData.getItemCount() > 0) {
|
|
||||||
Uri clipUri = clipData.getItemAt(0).getUri();
|
|
||||||
if (clipUri != null) {
|
|
||||||
return clipUri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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 = openSharedInputStream(uri)) {
|
|
||||||
if (in == null) {
|
|
||||||
throw new IOException("failed to open shared vault stream");
|
|
||||||
}
|
|
||||||
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int count;
|
|
||||||
while ((count = in.read(buffer)) >= 0) {
|
|
||||||
out.write(buffer, 0, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
|
||||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
|
||||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private InputStream openSharedInputStream(Uri uri) throws IOException {
|
|
||||||
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
String path = uri.getPath();
|
|
||||||
if (path == null || path.trim().isEmpty()) {
|
|
||||||
throw new IOException("file URI is missing a path");
|
|
||||||
}
|
|
||||||
return new FileInputStream(new File(path));
|
|
||||||
}
|
|
||||||
return getContentResolver().openInputStream(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveDisplayName(Uri uri) {
|
|
||||||
String displayName = queryDisplayName(uri);
|
|
||||||
if (!displayName.isEmpty()) {
|
|
||||||
return displayName;
|
|
||||||
}
|
|
||||||
String lastSegment = uri.getLastPathSegment();
|
|
||||||
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
|
||||||
return lastSegment.trim();
|
|
||||||
}
|
|
||||||
return DEFAULT_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String queryDisplayName(Uri uri) {
|
|
||||||
Cursor cursor = null;
|
|
||||||
try {
|
|
||||||
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
|
||||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
||||||
if (index >= 0) {
|
|
||||||
String value = cursor.getString(index);
|
|
||||||
if (value != null) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (RuntimeException err) {
|
|
||||||
Log.w(TAG, "failed to query display name", err);
|
|
||||||
} finally {
|
|
||||||
if (cursor != null) {
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logIntent(Intent intent) {
|
|
||||||
if (intent == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Log.i(TAG, "intent action=" + intent.getAction()
|
|
||||||
+ " type=" + intent.getType()
|
|
||||||
+ " data=" + intent.getData()
|
|
||||||
+ " flags=0x" + Integer.toHexString(intent.getFlags()));
|
|
||||||
ClipData clipData = intent.getClipData();
|
|
||||||
if (clipData != null) {
|
|
||||||
Log.i(TAG, "intent clip items=" + clipData.getItemCount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void launchMainActivity() {
|
|
||||||
Intent launch = new Intent();
|
|
||||||
launch.setClassName(this, "org.gioui.GioActivity");
|
|
||||||
startActivity(launch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package org.julianfamily.keepassgo;
|
|
||||||
|
|
||||||
import android.content.ContentProvider;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.database.MatrixCursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.ParcelFileDescriptor;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
|
|
||||||
public final class SharedVaultProvider extends ContentProvider {
|
|
||||||
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreate() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
|
||||||
File file = resolveSharedFile(uri);
|
|
||||||
String[] columns = projection;
|
|
||||||
if (columns == null || columns.length == 0) {
|
|
||||||
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
|
||||||
}
|
|
||||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
|
||||||
Object[] row = new Object[columns.length];
|
|
||||||
for (int i = 0; i < columns.length; i++) {
|
|
||||||
switch (columns[i]) {
|
|
||||||
case OpenableColumns.DISPLAY_NAME:
|
|
||||||
row[i] = file.getName();
|
|
||||||
break;
|
|
||||||
case OpenableColumns.SIZE:
|
|
||||||
row[i] = file.length();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
row[i] = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor.addRow(row);
|
|
||||||
return cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getType(Uri uri) {
|
|
||||||
return "application/x-keepass2";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Uri insert(Uri uri, ContentValues values) {
|
|
||||||
throw new UnsupportedOperationException("insert is not supported");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
|
||||||
throw new UnsupportedOperationException("delete is not supported");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
|
||||||
throw new UnsupportedOperationException("update is not supported");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
|
||||||
File file = resolveSharedFile(uri);
|
|
||||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Uri uriForFile(String name) {
|
|
||||||
return new Uri.Builder()
|
|
||||||
.scheme("content")
|
|
||||||
.authority(AUTHORITY)
|
|
||||||
.appendPath(name)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private File resolveSharedFile(Uri uri) {
|
|
||||||
if (getContext() == null) {
|
|
||||||
throw new IllegalStateException("provider context is unavailable");
|
|
||||||
}
|
|
||||||
String name = sanitizeFilename(uri.getLastPathSegment());
|
|
||||||
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sanitizeFilename(String name) {
|
|
||||||
if (name == null) {
|
|
||||||
return "shared-vault.kdbx";
|
|
||||||
}
|
|
||||||
String trimmed = name.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
return "shared-vault.kdbx";
|
|
||||||
}
|
|
||||||
return new File(trimmed).getName();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+626
@@ -0,0 +1,626 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
keepassgov1.UnimplementedVaultServiceServer
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
model vault.Model
|
||||||
|
locked bool
|
||||||
|
dirty bool
|
||||||
|
lifecycle lifecycleBackend
|
||||||
|
profiles map[string]passwords.Profile
|
||||||
|
clipboard clipboard.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleBackend interface {
|
||||||
|
Current() (vault.Model, error)
|
||||||
|
Open(string, vault.MasterKey) error
|
||||||
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
|
Save() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||||
|
return &Server{
|
||||||
|
model: model,
|
||||||
|
profiles: profiles,
|
||||||
|
clipboard: clipboardWriter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server {
|
||||||
|
server := NewServer(model, profiles, clipboardWriter)
|
||||||
|
server.lifecycle = lifecycle
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
|
Locked: s.locked,
|
||||||
|
Dirty: s.dirty,
|
||||||
|
EntryCount: uint32(len(s.model.Entries)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
||||||
|
if s.lifecycle == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
|
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := s.lifecycle.Current()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.model = model
|
||||||
|
s.locked = false
|
||||||
|
s.dirty = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return &keepassgov1.OpenVaultResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
||||||
|
if s.lifecycle == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: req.GetBaseUrl(),
|
||||||
|
Username: req.GetUsername(),
|
||||||
|
Password: req.GetPassword(),
|
||||||
|
}
|
||||||
|
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
|
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := s.lifecycle.Current()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.model = model
|
||||||
|
s.locked = false
|
||||||
|
s.dirty = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
||||||
|
if s.lifecycle == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lifecycle.Save(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dirty = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return &keepassgov1.SaveVaultResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.locked = true
|
||||||
|
|
||||||
|
return &keepassgov1.LockVaultResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.locked = false
|
||||||
|
|
||||||
|
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []vault.Entry
|
||||||
|
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||||
|
results := s.model.Search(req.GetQuery())
|
||||||
|
entries = make([]vault.Entry, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
entries = append(entries, result.Entry)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries = s.model.EntriesInPath(req.GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &keepassgov1.ListEntriesResponse{
|
||||||
|
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
resp.Entries = append(resp.Entries, entryToProto(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keepassgov1.ListGroupsResponse{
|
||||||
|
Names: s.model.ChildGroups(req.GetPath()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.CreateGroupResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.RenameGroupResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||||
|
if req.GetEntry() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entryFromProto(req.GetEntry())
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.locked {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
s.model.UpsertEntry(entry)
|
||||||
|
s.dirty = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.DeleteEntry(req.GetId()); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "delete entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored vault.Entry
|
||||||
|
for _, entry := range s.model.RecycleBin {
|
||||||
|
if entry.ID == req.GetId() {
|
||||||
|
restored = entry
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.RestoreEntry(req.GetId()); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "restore entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := findEntryByID(s.model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &keepassgov1.ListEntryHistoryResponse{
|
||||||
|
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
||||||
|
}
|
||||||
|
for _, historical := range entry.History {
|
||||||
|
resp.Entries = append(resp.Entries, entryToProto(historical))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := findEntryByID(s.model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &keepassgov1.ListTemplatesResponse{
|
||||||
|
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
|
||||||
|
}
|
||||||
|
for _, template := range s.model.Templates {
|
||||||
|
resp.Templates = append(resp.Templates, entryToProto(template))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
||||||
|
if req.GetTemplate() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "missing template")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entryFromProto(req.GetTemplate())
|
||||||
|
s.model.UpsertTemplate(entry)
|
||||||
|
s.dirty = true
|
||||||
|
|
||||||
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.DeleteTemplate(req.GetId()); err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
||||||
|
}
|
||||||
|
s.dirty = true
|
||||||
|
|
||||||
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
||||||
|
if req.GetOverrides() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "instantiate template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := findEntryByID(s.model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entry.Attachments))
|
||||||
|
for name := range entry.Attachments {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Attachments == nil {
|
||||||
|
entry.Attachments = map[string][]byte{}
|
||||||
|
}
|
||||||
|
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
||||||
|
s.model.Entries[index] = entry
|
||||||
|
s.dirty = true
|
||||||
|
|
||||||
|
return &keepassgov1.UploadAttachmentResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := findEntryByID(s.model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := entry.Attachments[req.GetName()]
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Error(codes.NotFound, "attachment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keepassgov1.DownloadAttachmentResponse{
|
||||||
|
Content: append([]byte(nil), content...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := entry.Attachments[req.GetName()]; !ok {
|
||||||
|
return nil, status.Error(codes.NotFound, "attachment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(entry.Attachments, req.GetName())
|
||||||
|
if len(entry.Attachments) == 0 {
|
||||||
|
entry.Attachments = nil
|
||||||
|
}
|
||||||
|
s.model.Entries[index] = entry
|
||||||
|
s.dirty = true
|
||||||
|
|
||||||
|
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
model := s.model
|
||||||
|
locked := s.locked
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
service := clipboard.Service{Writer: s.clipboard}
|
||||||
|
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, vault.ErrEntryNotFound):
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
case errors.Is(err, clipboard.ErrUnsupportedTarget):
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
default:
|
||||||
|
return nil, status.Errorf(codes.Internal, "copy entry field: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keepassgov1.CopyEntryFieldResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, ok := s.profiles[req.GetProfile()]
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := passwords.Generate(profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "generate password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
|
||||||
|
return &keepassgov1.Entry{
|
||||||
|
Id: entry.ID,
|
||||||
|
Title: entry.Title,
|
||||||
|
Username: entry.Username,
|
||||||
|
Password: entry.Password,
|
||||||
|
Url: entry.URL,
|
||||||
|
Notes: entry.Notes,
|
||||||
|
Tags: append([]string(nil), entry.Tags...),
|
||||||
|
Path: append([]string(nil), entry.Path...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
|
||||||
|
return vault.Entry{
|
||||||
|
ID: entry.GetId(),
|
||||||
|
Title: entry.GetTitle(),
|
||||||
|
Username: entry.GetUsername(),
|
||||||
|
Password: entry.GetPassword(),
|
||||||
|
URL: entry.GetUrl(),
|
||||||
|
Notes: entry.GetNotes(),
|
||||||
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
|
||||||
|
for _, entry := range model.Entries {
|
||||||
|
if entry.ID == id {
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vault.Entry{}, vault.ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) {
|
||||||
|
for i, entry := range model.Entries {
|
||||||
|
if entry.ID == id {
|
||||||
|
entry.Attachments = maps.Clone(entry.Attachments)
|
||||||
|
return entry, i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
|
||||||
|
return func(
|
||||||
|
ctx context.Context,
|
||||||
|
req any,
|
||||||
|
info *grpc.UnaryServerInfo,
|
||||||
|
handler grpc.UnaryHandler,
|
||||||
|
) (any, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
values := md.Get("authorization")
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "missing authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
if values[0] != "Bearer "+expectedToken {
|
||||||
|
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.ListEntries(context.Background(), &keepassgov1.ListEntriesRequest{})
|
||||||
|
if status.Code(err) != codes.Unauthenticated {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = true, want false at startup")
|
||||||
|
}
|
||||||
|
if statusResp.EntryCount == 0 {
|
||||||
|
t.Fatal("GetSessionStatus().EntryCount = 0, want non-zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
|
||||||
|
t.Fatalf("LockVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() after lock error = %v", err)
|
||||||
|
}
|
||||||
|
if !statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}); status.Code(err) != codes.FailedPrecondition {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want FailedPrecondition while locked", status.Code(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}); err != nil {
|
||||||
|
t.Fatalf("UnlockVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() after unlock error = %v", err)
|
||||||
|
}
|
||||||
|
if statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = true, want false after unlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lifecycle := &stubLifecycle{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||||
|
Path: "/tmp/test.kdbx",
|
||||||
|
Password: "correct horse battery staple",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("OpenVault() error = %v", err)
|
||||||
|
}
|
||||||
|
if lifecycle.openPath != "/tmp/test.kdbx" {
|
||||||
|
t.Fatalf("openPath = %q, want /tmp/test.kdbx", lifecycle.openPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() after open error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after open", listed.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}); err != nil {
|
||||||
|
t.Fatalf("SaveVault() error = %v", err)
|
||||||
|
}
|
||||||
|
if !lifecycle.saved {
|
||||||
|
t.Fatal("SaveVault() did not call lifecycle Save")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||||
|
BaseUrl: "https://dav.example.com",
|
||||||
|
Path: "vaults/main.kdbx",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "dav-token",
|
||||||
|
MasterPassword: "correct horse battery staple",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("OpenRemoteVault() error = %v", err)
|
||||||
|
}
|
||||||
|
if lifecycle.remoteBaseURL != "https://dav.example.com" || lifecycle.remotePath != "vaults/main.kdbx" {
|
||||||
|
t.Fatalf("remote open = %q %q, want dav.example.com vaults/main.kdbx", lifecycle.remoteBaseURL, lifecycle.remotePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Entries) != 1 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Entries[0].Title != "Git Server" {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
|
||||||
|
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
|
||||||
|
t.Fatalf("ListGroups().Names = %#v, want [Home Assistant Internet]", listed.Names)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
|
||||||
|
ParentPath: []string{"Root"},
|
||||||
|
Name: "Finance",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 3 || listed.Names[0] != "Finance" {
|
||||||
|
t.Fatalf("ListGroups().Names = %#v, want Finance present after create", listed.Names)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.RenameGroup(ctx, &keepassgov1.RenameGroupRequest{
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
NewName: "Infra",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RenameGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 3 || listed.Names[2] != "Infra" {
|
||||||
|
t.Fatalf("ListGroups().Names = %#v, want Infra after rename", listed.Names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GeneratePassword() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Password) < passwords.DefaultProfiles()["strong"].Length {
|
||||||
|
t.Fatalf("len(GeneratePassword().Password) = %d, want at least %d", len(resp.Password), passwords.DefaultProfiles()["strong"].Length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, clipboardWriter, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
||||||
|
Id: "git-server",
|
||||||
|
Target: "password",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CopyEntryField() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clipboardWriter.content != "token-1" {
|
||||||
|
t.Fatalf("clipboard content = %q, want %q", clipboardWriter.content, "token-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
||||||
|
Entry: &keepassgov1.Entry{
|
||||||
|
Id: "ha-codex",
|
||||||
|
Title: "Home Assistant (Codex)",
|
||||||
|
Username: "codex",
|
||||||
|
Password: "token-2",
|
||||||
|
Url: "https://lights.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Home Assistant"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||||
|
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
|
||||||
|
t.Fatalf("DeleteEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed.Entries) != 0 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RestoreEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if restored.Entry.Title != "Git Server" {
|
||||||
|
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTemplates() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
|
||||||
|
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||||
|
TemplateId: "website-login",
|
||||||
|
Overrides: &keepassgov1.Entry{
|
||||||
|
Id: "dynadot",
|
||||||
|
Title: "Dynadot",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "hunter2",
|
||||||
|
Url: "https://www.dynadot.com",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Tags: []string{"dns"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||||
|
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed.Entries) != 2 {
|
||||||
|
t.Fatalf("len(ListEntries().Entries) = %d, want 2 after template instantiation", len(listed.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
|
||||||
|
Template: &keepassgov1.Entry{
|
||||||
|
Id: "website-login",
|
||||||
|
Title: "Website Login Updated",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
if upserted.Template.Title != "Website Login Updated" {
|
||||||
|
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTemplates() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
|
||||||
|
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
|
||||||
|
t.Fatalf("DeleteTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err = client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTemplates() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Templates) != 0 {
|
||||||
|
t.Fatalf("ListTemplates().Templates = %#v, want empty after delete", listed.Templates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntryHistory() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(history.Entries) != 1 || history.Entries[0].Password != "token-0" {
|
||||||
|
t.Fatalf("ListEntryHistory().Entries = %#v, want old token entry", history.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
||||||
|
Id: "git-server",
|
||||||
|
HistoryIndex: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RestoreEntryHistory() error = %v", err)
|
||||||
|
}
|
||||||
|
if restored.Entry.Password != "token-0" {
|
||||||
|
t.Fatalf("RestoreEntryHistory().Entry.Password = %q, want token-0", restored.Entry.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
uploaded := []byte("attachment-content")
|
||||||
|
|
||||||
|
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
||||||
|
EntryId: "git-server",
|
||||||
|
Name: "token.txt",
|
||||||
|
Content: uploaded,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UploadAttachment() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAttachments() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 1 || listed.Names[0] != "token.txt" {
|
||||||
|
t.Fatalf("ListAttachments().Names = %#v, want [token.txt]", listed.Names)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
||||||
|
EntryId: "git-server",
|
||||||
|
Name: "token.txt",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DownloadAttachment() error = %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(downloaded.Content, uploaded) {
|
||||||
|
t.Fatalf("DownloadAttachment().Content = %q, want %q", downloaded.Content, uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
||||||
|
EntryId: "git-server",
|
||||||
|
Name: "token.txt",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachment() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAttachments() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 0 {
|
||||||
|
t.Fatalf("ListAttachments().Names = %#v, want empty after delete", listed.Names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
|
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||||
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, NewServer(
|
||||||
|
vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
History: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server-h1",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-0",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "ha-codex",
|
||||||
|
Title: "Home Assistant (Codex)",
|
||||||
|
Username: "codex",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://lights.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Home Assistant"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "website-login",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
URL: "https://example.com",
|
||||||
|
Notes: "Reusable template for website accounts.",
|
||||||
|
Tags: []string{"template", "web"},
|
||||||
|
Path: []string{"Templates"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passwords.DefaultProfiles(),
|
||||||
|
clipboardWriter,
|
||||||
|
))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return listener.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.NewClient() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
server.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
|
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||||
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
||||||
|
vault.Model{},
|
||||||
|
passwords.DefaultProfiles(),
|
||||||
|
clipboardWriter,
|
||||||
|
lifecycle,
|
||||||
|
))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return listener.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.NewClient() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
server.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryClipboardWriter struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *memoryClipboardWriter) WriteText(text string) error {
|
||||||
|
w.content = text
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubLifecycle struct {
|
||||||
|
model vault.Model
|
||||||
|
openPath string
|
||||||
|
remoteBaseURL string
|
||||||
|
remotePath string
|
||||||
|
saved bool
|
||||||
|
locked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Current() (vault.Model, error) {
|
||||||
|
if s.locked {
|
||||||
|
return vault.Model{}, session.ErrLocked
|
||||||
|
}
|
||||||
|
return s.model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
|
||||||
|
s.openPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
s.remoteBaseURL = client.BaseURL
|
||||||
|
s.remotePath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Save() error {
|
||||||
|
s.saved = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Section string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SectionEntries Section = ""
|
||||||
|
SectionTemplates Section = "templates"
|
||||||
|
SectionRecycleBin Section = "recycle-bin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CurrentSession interface {
|
||||||
|
Current() (vault.Model, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
Replace(vault.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LockableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
Lock() error
|
||||||
|
Unlock(vault.MasterKey) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
Save() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
Create(vault.Model, vault.MasterKey) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
Open(string, vault.MasterKey) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveAsSession interface {
|
||||||
|
CurrentSession
|
||||||
|
SaveAs(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteOpenableSession interface {
|
||||||
|
CurrentSession
|
||||||
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Session CurrentSession
|
||||||
|
Section Section
|
||||||
|
CurrentPath []string
|
||||||
|
SearchQuery string
|
||||||
|
SelectedEntryID string
|
||||||
|
Dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := s.entriesForSection(model)
|
||||||
|
if strings.TrimSpace(s.SearchQuery) != "" {
|
||||||
|
return filterEntries(entries, s.SearchQuery), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Section == SectionEntries {
|
||||||
|
return model.EntriesInPath(s.CurrentPath), nil
|
||||||
|
}
|
||||||
|
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesInPath(entries, s.CurrentPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ChildGroups() ([]string, error) {
|
||||||
|
if strings.TrimSpace(s.SearchQuery) != "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Section != SectionEntries {
|
||||||
|
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
||||||
|
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
||||||
|
}
|
||||||
|
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.ChildGroups(s.CurrentPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) SelectVisibleIndex(index int) error {
|
||||||
|
entries, err := s.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(entries) {
|
||||||
|
return fmt.Errorf("visible index %d out of range", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SelectedEntryID = entries[index].ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ToggleVisibleIndex(index int) error {
|
||||||
|
entries, err := s.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(entries) {
|
||||||
|
return fmt.Errorf("visible index %d out of range", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.SelectedEntryID == entries[index].ID {
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SelectedEntryID = entries[index].ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) currentModel() (vault.Model, error) {
|
||||||
|
if s.Session == nil {
|
||||||
|
return vault.Model{}, nil
|
||||||
|
}
|
||||||
|
return s.Session.Current()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||||
|
switch s.Section {
|
||||||
|
case SectionTemplates:
|
||||||
|
return slices.Clone(model.Templates)
|
||||||
|
case SectionRecycleBin:
|
||||||
|
return slices.Clone(model.RecycleBin)
|
||||||
|
default:
|
||||||
|
return slices.Clone(model.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
||||||
|
var out []vault.Entry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if slices.Equal(entry.Path, path) {
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterEntries(entries []vault.Entry, query string) []vault.Entry {
|
||||||
|
query = strings.TrimSpace(strings.ToLower(query))
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []vault.Entry
|
||||||
|
for _, entry := range entries {
|
||||||
|
haystack := strings.ToLower(
|
||||||
|
entry.Title + " " +
|
||||||
|
entry.Username + " " +
|
||||||
|
entry.URL + " " +
|
||||||
|
strings.Join(entry.Path, " "),
|
||||||
|
)
|
||||||
|
if !strings.Contains(haystack, query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func childGroups(entries []vault.Entry, path []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var groups []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if len(path) > len(entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Equal(entry.Path[:len(path)], path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(entry.Path) == len(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group := entry.Path[len(path)]
|
||||||
|
if seen[group] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[group] = true
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
|
slices.Sort(groups)
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DeleteSelectedEntry() error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeleteEntry(s.SelectedEntryID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RestoreEntry(id string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.RestoreEntry(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model.UpsertEntry(entry)
|
||||||
|
session.Replace(model)
|
||||||
|
s.SelectedEntryID = entry.ID
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model.UpsertTemplate(entry)
|
||||||
|
session.Replace(model)
|
||||||
|
s.SelectedEntryID = entry.ID
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return vault.Entry{}, fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := model.InstantiateTemplate(templateID, overrides)
|
||||||
|
if err != nil {
|
||||||
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.SelectedEntryID = entry.ID
|
||||||
|
s.Dirty = true
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DeleteTemplate(id string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeleteTemplate(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
if s.SelectedEntryID == id {
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
}
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return vault.Entry{}, fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID)
|
||||||
|
if err != nil {
|
||||||
|
return vault.Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.SelectedEntryID = duplicate.ID
|
||||||
|
s.Dirty = true
|
||||||
|
return duplicate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Lock() error {
|
||||||
|
session, ok := s.Session.(LockableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not lockable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Lock(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Unlock(key vault.MasterKey) error {
|
||||||
|
session, ok := s.Session.(LockableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not lockable")
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.Unlock(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) EnterGroup(name string) {
|
||||||
|
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) NavigateToPath(path []string) {
|
||||||
|
s.CurrentPath = append([]string(nil), path...)
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Save() error {
|
||||||
|
session, ok := s.Session.(SaveableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not saveable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) CreateVault(key vault.MasterKey) error {
|
||||||
|
session, ok := s.Session.(CreateableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not createable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Create(vault.Model{}, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.CurrentPath = nil
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) OpenVault(path string, key vault.MasterKey) error {
|
||||||
|
session, ok := s.Session.(OpenableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not openable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Open(path, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.CurrentPath = nil
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) SaveAs(path string) error {
|
||||||
|
session, ok := s.Session.(SaveAsSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not save-as capable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.SaveAs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error {
|
||||||
|
session, ok := s.Session.(RemoteOpenableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not remote-openable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.OpenRemote(client, path, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.CurrentPath = nil
|
||||||
|
s.SelectedEntryID = ""
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) CreateGroup(name string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model.CreateGroup(s.CurrentPath, name)
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RenameCurrentGroup(newName string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
if len(s.CurrentPath) > 0 {
|
||||||
|
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
|
||||||
|
}
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) MoveSelectedEntry(path []string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range model.Entries {
|
||||||
|
if model.Entries[i].ID != s.SelectedEntryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if model.Entries[i].Attachments == nil {
|
||||||
|
model.Entries[i].Attachments = map[string][]byte{}
|
||||||
|
}
|
||||||
|
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return vault.ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range model.Entries {
|
||||||
|
if model.Entries[i].ID != s.SelectedEntryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(model.Entries[i].Attachments, name)
|
||||||
|
if len(model.Entries[i].Attachments) == 0 {
|
||||||
|
model.Entries[i].Attachments = nil
|
||||||
|
}
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return vault.ErrEntryNotFound
|
||||||
|
}
|
||||||
@@ -0,0 +1,956 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
titles := make([]string, 0, len(got))
|
||||||
|
for _, entry := range got {
|
||||||
|
titles = append(titles, entry.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
|
||||||
|
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe", "Internet"},
|
||||||
|
SearchQuery: "lights",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionTemplates,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionRecycleBin,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.ChildGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionTemplates,
|
||||||
|
CurrentPath: []string{"Templates"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.ChildGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"Infra", "Web"}) {
|
||||||
|
t.Fatalf("ChildGroups() = %v, want [Infra Web]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.SelectVisibleIndex(1); err != nil {
|
||||||
|
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := state.SelectedEntryID; got != "git-server" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.ToggleVisibleIndex(1); err != nil {
|
||||||
|
t.Fatalf("ToggleVisibleIndex() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID after toggle = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{err: session.ErrLocked},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := state.VisibleEntries()
|
||||||
|
if !errors.Is(err, session.ErrLocked) {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubSession struct {
|
||||||
|
model vault.Model
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubSession) Current() (vault.Model, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return vault.Model{}, s.err
|
||||||
|
}
|
||||||
|
return s.model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.DeleteSelectedEntry(); err != nil {
|
||||||
|
t.Fatalf("DeleteSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sess.model.Entries) != 0 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
|
||||||
|
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.RestoreEntry("git-server"); err != nil {
|
||||||
|
t.Fatalf("RestoreEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].ID != "git-server" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{
|
||||||
|
model: vault.Model{},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := vault.Entry{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
if err := state.UpsertEntry(entry); err != nil {
|
||||||
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.SelectedEntryID; got != "git-server" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Password != "token-1" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after upsert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "website-login",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
URL: "https://example.com",
|
||||||
|
Notes: "Reusable template for website accounts.",
|
||||||
|
Path: []string{"Templates"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
|
||||||
|
ID: "dynadot",
|
||||||
|
Title: "Dynadot",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "hunter2",
|
||||||
|
URL: "https://www.dynadot.com",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Notes != "Reusable template for website accounts." {
|
||||||
|
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.SelectedEntryID; got != "dynadot" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != "dynadot" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after template instantiation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
if err := state.UpsertTemplate(vault.Entry{
|
||||||
|
ID: "tpl-1",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Path: []string{"Templates"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" {
|
||||||
|
t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "tpl-1" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after template upsert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
SelectedEntryID: "tpl-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.DeleteTemplate("tpl-1"); err != nil {
|
||||||
|
t.Fatalf("DeleteTemplate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sess.model.Templates) != 0 {
|
||||||
|
t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after template delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if duplicate.ID != "git-server-copy" {
|
||||||
|
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "git-server-copy" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if len(sess.model.Entries) != 2 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Password: "new-token",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
History: []vault.Entry{
|
||||||
|
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.RestoreSelectedEntryVersion(0); err != nil {
|
||||||
|
t.Fatalf("RestoreSelectedEntryVersion() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sess.model.Entries[0].Password; got != "old-token" {
|
||||||
|
t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token")
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after history restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveClearsDirtyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &saveableStubSession{}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.Save(); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after save")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.saveCalls != 1 {
|
||||||
|
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("CreateVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.createCalls != 1 {
|
||||||
|
t.Fatalf("createCalls = %d, want 1", sess.createCalls)
|
||||||
|
}
|
||||||
|
if len(state.CurrentPath) != 0 {
|
||||||
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after create")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("OpenVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.openPath != "/tmp/test.kdbx" {
|
||||||
|
t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx")
|
||||||
|
}
|
||||||
|
if len(state.CurrentPath) != 0 {
|
||||||
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAsClearsDirtyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.SaveAs("/tmp/other.kdbx"); err != nil {
|
||||||
|
t.Fatalf("SaveAs() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.saveAsPath != "/tmp/other.kdbx" {
|
||||||
|
t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx")
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after save-as")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{}
|
||||||
|
client := webdav.Client{BaseURL: "https://example.com"}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("OpenRemoteVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.remotePath != "vaults/main.kdbx" {
|
||||||
|
t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx")
|
||||||
|
}
|
||||||
|
if len(state.CurrentPath) != 0 {
|
||||||
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after remote open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lockableStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.Lock(); err != nil {
|
||||||
|
t.Fatalf("Lock() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty after lock", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := state.VisibleEntries()
|
||||||
|
if !errors.Is(err, session.ErrLocked) {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lockableStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locked: true,
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Unlock() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != "git-server" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
CurrentPath: []string{"Root"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
state.EnterGroup("Internet")
|
||||||
|
|
||||||
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) {
|
||||||
|
t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if got := state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||||
|
|
||||||
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||||
|
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if got := state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: testVaultModel()}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.CreateGroup("Finance"); err != nil {
|
||||||
|
t.Fatalf("CreateGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.ChildGroups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: testVaultModel()}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.RenameCurrentGroup("Infra"); err != nil {
|
||||||
|
t.Fatalf("RenameCurrentGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) {
|
||||||
|
t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after RenameCurrentGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: testVaultModel()}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "dynadot",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||||
|
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: testVaultModel()}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "dynadot",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
|
||||||
|
t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(got[0].Attachments["token.txt"]) != "secret" {
|
||||||
|
t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret")
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testVaultModel()
|
||||||
|
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
|
||||||
|
sess := &mutableStubSession{model: model}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "dynadot",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got[0].Attachments) != 0 {
|
||||||
|
t.Fatalf("attachments = %#v, want empty", got[0].Attachments)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mutableStubSession struct {
|
||||||
|
model vault.Model
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mutableStubSession) Current() (vault.Model, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return vault.Model{}, s.err
|
||||||
|
}
|
||||||
|
return s.model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mutableStubSession) Replace(model vault.Model) {
|
||||||
|
s.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVaultModel() vault.Model {
|
||||||
|
return vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lockableStubSession struct {
|
||||||
|
model vault.Model
|
||||||
|
locked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lockableStubSession) Current() (vault.Model, error) {
|
||||||
|
if s.locked {
|
||||||
|
return vault.Model{}, session.ErrLocked
|
||||||
|
}
|
||||||
|
return s.model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lockableStubSession) Lock() error {
|
||||||
|
s.locked = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lockableStubSession) Unlock(vault.MasterKey) error {
|
||||||
|
s.locked = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveableStubSession struct {
|
||||||
|
saveCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *saveableStubSession) Current() (vault.Model, error) {
|
||||||
|
return vault.Model{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *saveableStubSession) Save() error {
|
||||||
|
s.saveCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleStubSession struct {
|
||||||
|
createCalls int
|
||||||
|
openPath string
|
||||||
|
saveAsPath string
|
||||||
|
remotePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||||
|
return vault.Model{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||||
|
s.createCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error {
|
||||||
|
s.openPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||||
|
s.saveAsPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
s.remotePath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# KeePassGO Browser Extension
|
|
||||||
|
|
||||||
Shared extension assets for Firefox and Chromium-based browsers live here.
|
|
||||||
|
|
||||||
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
|
|
||||||
|
|
||||||
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
|
||||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
|
||||||
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
|
|
||||||
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
|
|
||||||
- `options.html` stores the API token in browser extension storage
|
|
||||||
|
|
||||||
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
|
||||||
|
|
||||||
Quick extension-side checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Reproducible Chromium validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make browser-extension-validate
|
|
||||||
```
|
|
||||||
|
|
||||||
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
const ext = globalThis.browser ?? globalThis.chrome;
|
|
||||||
const nativeHost = "com.keepassgo.browser";
|
|
||||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
|
||||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
|
||||||
const defaultSettings = {
|
|
||||||
bearerToken: ""
|
|
||||||
};
|
|
||||||
const pageStatePrefix = "keepassgo-page-state:";
|
|
||||||
const matchCacheTTL = 30 * 1000;
|
|
||||||
const pendingPollMillis = 1500;
|
|
||||||
const pageStates = new Map();
|
|
||||||
const refreshJobs = new Map();
|
|
||||||
const pendingPollers = new Map();
|
|
||||||
|
|
||||||
function storageGet(keys) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.storage.local.get(keys);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.storage.local.get(keys, (value) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function storageSet(value) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.storage.local.set(value);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.storage.local.set(value, () => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionArea() {
|
|
||||||
return ext.storage?.session ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionStorageGet(keys) {
|
|
||||||
const area = sessionArea();
|
|
||||||
if (!area) {
|
|
||||||
return Promise.resolve({});
|
|
||||||
}
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return area.get(keys).then((value) => value || {});
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
area.get(keys, (value) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(value || {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionStorageSet(value) {
|
|
||||||
const area = sessionArea();
|
|
||||||
if (!area) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return area.set(value);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
area.set(value, () => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionStorageRemove(keys) {
|
|
||||||
const area = sessionArea();
|
|
||||||
if (!area) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return area.remove(keys);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
area.remove(keys, () => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabsQuery(query) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.tabs.query(query);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.tabs.query(query, (tabs) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(tabs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabsGet(tabId) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.tabs.get(tabId);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.tabs.get(tabId, (tab) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(tab);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabsSendMessage(tabId, message) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.tabs.sendMessage(tabId, message);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.tabs.sendMessage(tabId, message, (response) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectNative(message) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.runtime.sendNativeMessage(nativeHost, message);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
const stored = await storageGet(["bearerToken"]);
|
|
||||||
return {
|
|
||||||
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportsPageStateURL(rawURL) {
|
|
||||||
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageStateKey(tabId) {
|
|
||||||
return `${pageStatePrefix}${String(tabId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneTarget(target) {
|
|
||||||
return target && typeof target === "object" ? { ...target } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageState(state) {
|
|
||||||
return {
|
|
||||||
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
|
||||||
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
|
|
||||||
configured: Boolean(state?.configured),
|
|
||||||
success: state?.success !== false,
|
|
||||||
status: state?.status ?? null,
|
|
||||||
matches: Array.isArray(state?.matches) ? state.matches : [],
|
|
||||||
error: typeof state?.error === "string" ? state.error : "",
|
|
||||||
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
|
|
||||||
signature: typeof state?.signature === "string" ? state.signature : "",
|
|
||||||
focusTarget: cloneTarget(state?.focusTarget),
|
|
||||||
pendingFill: Boolean(state?.pendingFill),
|
|
||||||
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
|
||||||
pendingTarget: cloneTarget(state?.pendingTarget),
|
|
||||||
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
|
||||||
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
|
||||||
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultPageState(tabId, pageUrl) {
|
|
||||||
return normalizePageState({
|
|
||||||
tabId,
|
|
||||||
pageUrl,
|
|
||||||
configured: true,
|
|
||||||
success: true,
|
|
||||||
status: null,
|
|
||||||
matches: [],
|
|
||||||
error: "",
|
|
||||||
pageHasLoginForm: false,
|
|
||||||
signature: "",
|
|
||||||
focusTarget: null,
|
|
||||||
pendingFill: false,
|
|
||||||
pendingEntryId: "",
|
|
||||||
pendingTarget: null,
|
|
||||||
pendingMessage: "",
|
|
||||||
lastFilledEntryId: "",
|
|
||||||
updatedAt: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPageState(tabId, pageUrl) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return defaultPageState(null, pageUrl || "");
|
|
||||||
}
|
|
||||||
const existing = pageStates.get(tabId);
|
|
||||||
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
|
|
||||||
return normalizePageState(existing);
|
|
||||||
}
|
|
||||||
const stored = await sessionStorageGet(pageStateKey(tabId));
|
|
||||||
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
|
|
||||||
if (pageUrl && state.pageUrl !== pageUrl) {
|
|
||||||
return defaultPageState(tabId, pageUrl);
|
|
||||||
}
|
|
||||||
pageStates.set(tabId, state);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setPageState(tabId, nextState) {
|
|
||||||
const state = normalizePageState({ ...nextState, tabId });
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
pageStates.set(tabId, state);
|
|
||||||
await sessionStorageSet({ [pageStateKey(tabId)]: state });
|
|
||||||
await updateActionState(tabId, state);
|
|
||||||
await notifyContentState(tabId, state);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPendingPoll(tabId) {
|
|
||||||
const timer = pendingPollers.get(tabId);
|
|
||||||
if (timer !== undefined) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
pendingPollers.delete(tabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearPageState(tabId) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pageStates.delete(tabId);
|
|
||||||
refreshJobs.delete(tabId);
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
await sessionStorageRemove(pageStateKey(tabId));
|
|
||||||
await clearActionState(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeError(error) {
|
|
||||||
return error instanceof Error ? error.message : String(error || "Unknown error");
|
|
||||||
}
|
|
||||||
|
|
||||||
function approvalHintForState(state) {
|
|
||||||
if (!state.pendingFill) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePendingPoll(tabId, pageUrl) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pendingPollers.delete(tabId);
|
|
||||||
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
|
|
||||||
}, pendingPollMillis);
|
|
||||||
pendingPollers.set(tabId, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function notifyContentState(tabId, state) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await tabsSendMessage(tabId, {
|
|
||||||
type: "keepassgo-page-state",
|
|
||||||
state
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Ignore pages without a ready content script.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearActionState(tabId) {
|
|
||||||
if (!Number.isInteger(tabId) || !ext.action) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.allSettled([
|
|
||||||
ext.action.setBadgeText({ tabId, text: "" }),
|
|
||||||
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionPresentationForState(state) {
|
|
||||||
let badgeText = "";
|
|
||||||
let title = "KeePassGO Browser";
|
|
||||||
let color = "#255f4a";
|
|
||||||
|
|
||||||
if (state.pendingFill) {
|
|
||||||
badgeText = "!";
|
|
||||||
color = "#9f5f0e";
|
|
||||||
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
|
||||||
} else if (!state.configured) {
|
|
||||||
title = "Configure KeePassGO Browser in extension settings";
|
|
||||||
} else if (!state.success) {
|
|
||||||
badgeText = "!";
|
|
||||||
color = "#9f2f2f";
|
|
||||||
title = state.error || "KeePassGO is unavailable for this page";
|
|
||||||
} else if (state.status?.locked) {
|
|
||||||
title = "Unlock KeePassGO to fill this page";
|
|
||||||
} else if (state.pageHasLoginForm && state.matches.length > 0) {
|
|
||||||
badgeText = String(Math.min(state.matches.length, 9));
|
|
||||||
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
|
|
||||||
} else if (state.pageHasLoginForm) {
|
|
||||||
title = "KeePassGO found no matching entries on this page";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { badgeText, title, color };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateActionState(tabId, state) {
|
|
||||||
if (!Number.isInteger(tabId) || !ext.action) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const presentation = actionPresentationForState(state);
|
|
||||||
await Promise.allSettled([
|
|
||||||
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
|
|
||||||
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
|
|
||||||
ext.action.setTitle({ tabId, title: presentation.title })
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function activePageContext() {
|
|
||||||
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
|
||||||
return {
|
|
||||||
tabId: Number.isInteger(tab?.id) ? tab.id : null,
|
|
||||||
url: typeof tab?.url === "string" ? tab.url : ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanTabForLoginForm(tabId) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
|
|
||||||
return {
|
|
||||||
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
|
|
||||||
focusTarget: cloneTarget(response?.focusTarget),
|
|
||||||
signature: typeof response?.signature === "string" ? response.signature : ""
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
|
||||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldReuseMatches(state, force) {
|
|
||||||
if (force || state.pendingFill) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenPendingApprovalCount(status) {
|
|
||||||
return Number(status?.tokenPendingApprovalCount || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStatus(settings) {
|
|
||||||
if (!settings.bearerToken) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
configured: false,
|
|
||||||
status: null,
|
|
||||||
error: "Set an API token in extension settings."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const status = await connectNative({
|
|
||||||
action: "status",
|
|
||||||
bearerToken: settings.bearerToken
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: Boolean(status?.success),
|
|
||||||
configured: true,
|
|
||||||
status: status?.status ?? null,
|
|
||||||
error: status?.error ?? ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPageState(tabId, pageUrl, options = {}) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
return defaultPageState(null, pageUrl || "");
|
|
||||||
}
|
|
||||||
const force = Boolean(options.force);
|
|
||||||
const existingJob = refreshJobs.get(tabId);
|
|
||||||
if (existingJob && !force) {
|
|
||||||
return existingJob;
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = (async () => {
|
|
||||||
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
|
|
||||||
if (!supportsPageStateURL(resolvedURL)) {
|
|
||||||
const tab = await tabsGet(tabId).catch(() => null);
|
|
||||||
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
|
|
||||||
}
|
|
||||||
if (!supportsPageStateURL(resolvedURL)) {
|
|
||||||
await clearPageState(tabId);
|
|
||||||
return defaultPageState(tabId, resolvedURL || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = await getPageState(tabId, resolvedURL);
|
|
||||||
if (state.pageUrl !== resolvedURL) {
|
|
||||||
state = defaultPageState(tabId, resolvedURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scan = typeof options.pageHasLoginForm === "boolean"
|
|
||||||
? {
|
|
||||||
pageHasLoginForm: options.pageHasLoginForm,
|
|
||||||
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
|
|
||||||
signature: typeof options.signature === "string" ? options.signature : state.signature
|
|
||||||
}
|
|
||||||
: await scanTabForLoginForm(tabId);
|
|
||||||
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
pageUrl: resolvedURL,
|
|
||||||
pageHasLoginForm: scan.pageHasLoginForm,
|
|
||||||
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
|
|
||||||
signature: typeof scan.signature === "string" ? scan.signature : state.signature
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = await loadSettings();
|
|
||||||
const statusInfo = await fetchStatus(settings).catch((error) => ({
|
|
||||||
success: false,
|
|
||||||
configured: true,
|
|
||||||
status: null,
|
|
||||||
error: describeError(error)
|
|
||||||
}));
|
|
||||||
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
configured: statusInfo.configured,
|
|
||||||
success: statusInfo.success,
|
|
||||||
status: statusInfo.status,
|
|
||||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
|
|
||||||
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
|
|
||||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
|
||||||
: "",
|
|
||||||
error: statusInfo.error
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
|
|
||||||
state.matches = [];
|
|
||||||
state.updatedAt = Date.now();
|
|
||||||
const saved = await setPageState(tabId, state);
|
|
||||||
if (saved.pendingFill) {
|
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
|
||||||
} else {
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldReuseMatches(state, force)) {
|
|
||||||
const saved = await setPageState(tabId, state);
|
|
||||||
if (saved.pendingFill) {
|
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
|
||||||
} else {
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = await connectNative({
|
|
||||||
action: "find-logins",
|
|
||||||
bearerToken: settings.bearerToken,
|
|
||||||
url: resolvedURL
|
|
||||||
});
|
|
||||||
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
success: Boolean(matches?.success),
|
|
||||||
status: matches?.status ?? state.status,
|
|
||||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
|
||||||
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
|
|
||||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
|
||||||
: "",
|
|
||||||
matches: Array.isArray(matches?.matches) ? matches.matches : [],
|
|
||||||
error: matches?.error ?? "",
|
|
||||||
updatedAt: Date.now()
|
|
||||||
};
|
|
||||||
const saved = await setPageState(tabId, state);
|
|
||||||
if (saved.pendingFill) {
|
|
||||||
schedulePendingPoll(tabId, resolvedURL);
|
|
||||||
} else {
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
})().finally(() => {
|
|
||||||
if (refreshJobs.get(tabId) === job) {
|
|
||||||
refreshJobs.delete(tabId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refreshJobs.set(tabId, job);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function statusForPage(options = {}) {
|
|
||||||
let page = await activePageContext();
|
|
||||||
if (Number.isInteger(options.tabId)) {
|
|
||||||
const tab = await tabsGet(options.tabId).catch(() => null);
|
|
||||||
page = {
|
|
||||||
tabId: options.tabId,
|
|
||||||
url: typeof tab?.url === "string" ? tab.url : ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (page.tabId == null) {
|
|
||||||
return defaultPageState(null, page.url);
|
|
||||||
}
|
|
||||||
if (!options.force) {
|
|
||||||
const cached = await getPageState(page.tabId, page.url);
|
|
||||||
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return refreshPageState(page.tabId, page.url, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fillLogin(tabId, entryId) {
|
|
||||||
if (!Number.isInteger(tabId)) {
|
|
||||||
throw new Error("No active tab is available.");
|
|
||||||
}
|
|
||||||
const tab = await tabsGet(tabId);
|
|
||||||
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
|
||||||
if (!supportsPageStateURL(pageUrl)) {
|
|
||||||
throw new Error("This page cannot be filled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = await getPageState(tabId, pageUrl);
|
|
||||||
state = await setPageState(tabId, {
|
|
||||||
...state,
|
|
||||||
pageUrl,
|
|
||||||
pendingFill: true,
|
|
||||||
pendingEntryId: String(entryId || "").trim(),
|
|
||||||
pendingTarget: cloneTarget(state.focusTarget),
|
|
||||||
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
|
|
||||||
error: "",
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
schedulePendingPoll(tabId, pageUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = await loadSettings();
|
|
||||||
if (!settings.bearerToken) {
|
|
||||||
throw new Error("API token is not configured.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await connectNative({
|
|
||||||
action: "get-login",
|
|
||||||
bearerToken: settings.bearerToken,
|
|
||||||
entryId,
|
|
||||||
url: pageUrl
|
|
||||||
});
|
|
||||||
if (!response?.success || !response.credential) {
|
|
||||||
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fillResponse = await tabsSendMessage(tabId, {
|
|
||||||
type: "keepassgo-fill-credential",
|
|
||||||
credential: response.credential,
|
|
||||||
target: state.pendingTarget
|
|
||||||
});
|
|
||||||
if (!fillResponse?.ok) {
|
|
||||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
state = await setPageState(tabId, {
|
|
||||||
...state,
|
|
||||||
pendingFill: false,
|
|
||||||
pendingEntryId: "",
|
|
||||||
pendingTarget: null,
|
|
||||||
pendingMessage: "",
|
|
||||||
lastFilledEntryId: String(entryId || "").trim(),
|
|
||||||
error: "",
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
return {
|
|
||||||
credential: response.credential,
|
|
||||||
pageUrl,
|
|
||||||
state
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
state = await setPageState(tabId, {
|
|
||||||
...state,
|
|
||||||
pendingFill: false,
|
|
||||||
pendingEntryId: "",
|
|
||||||
pendingTarget: null,
|
|
||||||
pendingMessage: "",
|
|
||||||
error: describeError(error),
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
clearPendingPoll(tabId);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshActivePage(options = {}) {
|
|
||||||
const page = await activePageContext();
|
|
||||||
if (page.tabId == null) {
|
|
||||||
return defaultPageState(null, page.url);
|
|
||||||
}
|
|
||||||
return refreshPageState(page.tabId, page.url, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backgroundTestExports = {
|
|
||||||
normalizePageState,
|
|
||||||
actionPresentationForState,
|
|
||||||
shouldReuseMatches,
|
|
||||||
tokenPendingApprovalCount,
|
|
||||||
defaultSettings
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNodeTestEnv) {
|
|
||||||
module.exports = backgroundTestExports;
|
|
||||||
} else {
|
|
||||||
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
||||||
(async () => {
|
|
||||||
switch (message?.type) {
|
|
||||||
case "keepassgo-popup-state":
|
|
||||||
sendResponse(await statusForPage({
|
|
||||||
force: Boolean(message.force),
|
|
||||||
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
case "keepassgo-fill-entry": {
|
|
||||||
const targetTabID = Number.isInteger(message?.tabId)
|
|
||||||
? message.tabId
|
|
||||||
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
|
||||||
if (Number.isInteger(targetTabID) && message.target) {
|
|
||||||
const targetState = await getPageState(targetTabID, "");
|
|
||||||
await setPageState(targetTabID, {
|
|
||||||
...targetState,
|
|
||||||
focusTarget: cloneTarget(message.target)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "keepassgo-load-settings":
|
|
||||||
sendResponse({ success: true, settings: await loadSettings() });
|
|
||||||
return;
|
|
||||||
case "keepassgo-save-settings":
|
|
||||||
await storageSet({
|
|
||||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
|
||||||
});
|
|
||||||
await refreshActivePage({ force: true }).catch(() => null);
|
|
||||||
sendResponse({ success: true });
|
|
||||||
return;
|
|
||||||
case "keepassgo-page-ready":
|
|
||||||
if (Number.isInteger(sender?.tab?.id)) {
|
|
||||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
|
||||||
force: Boolean(message.force),
|
|
||||||
pageHasLoginForm: Boolean(message.pageHasLoginForm),
|
|
||||||
focusTarget: cloneTarget(message.focusTarget),
|
|
||||||
signature: typeof message.signature === "string" ? message.signature : ""
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendResponse(defaultPageState(null, ""));
|
|
||||||
return;
|
|
||||||
case "keepassgo-refresh-page-state":
|
|
||||||
if (Number.isInteger(sender?.tab?.id)) {
|
|
||||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendResponse(defaultPageState(null, ""));
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
|
||||||
}
|
|
||||||
})().catch((error) => {
|
|
||||||
sendResponse({ success: false, error: describeError(error) });
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
|
||||||
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
|
||||||
});
|
|
||||||
|
|
||||||
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
|
||||||
if (typeof changeInfo.url === "string") {
|
|
||||||
void clearPageState(tabId).catch(() => null);
|
|
||||||
}
|
|
||||||
if (changeInfo.status === "complete") {
|
|
||||||
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ext.tabs?.onRemoved?.addListener((tabId) => {
|
|
||||||
void clearPageState(tabId).catch(() => null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
const test = require("node:test");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
|
|
||||||
const background = require("./background.js");
|
|
||||||
|
|
||||||
test("normalizePageState preserves focused and pending field targets", () => {
|
|
||||||
const state = background.normalizePageState({
|
|
||||||
tabId: 7,
|
|
||||||
pageUrl: "https://vault.example.invalid/login",
|
|
||||||
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
|
||||||
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
|
||||||
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
|
||||||
const recentState = {
|
|
||||||
pageHasLoginForm: true,
|
|
||||||
matches: [{ id: "vault-console" }],
|
|
||||||
pendingFill: false,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
};
|
|
||||||
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
|
||||||
|
|
||||||
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
|
||||||
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
|
||||||
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("actionPresentationForState prioritizes approval visibility", () => {
|
|
||||||
const presentation = background.actionPresentationForState({
|
|
||||||
pendingFill: true,
|
|
||||||
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
|
||||||
configured: true,
|
|
||||||
success: true,
|
|
||||||
pageHasLoginForm: true,
|
|
||||||
matches: [{ id: "vault-console" }]
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(presentation.badgeText, "!");
|
|
||||||
assert.equal(presentation.color, "#9f5f0e");
|
|
||||||
assert.match(presentation.title, /approve/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
|
||||||
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
|
||||||
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
|
||||||
assert.equal(background.defaultSettings.bearerToken, "");
|
|
||||||
});
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
const ext = globalThis.browser ?? globalThis.chrome;
|
|
||||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
|
||||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
|
||||||
|
|
||||||
function runtimeSend(message) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return ext.runtime.sendMessage(message);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ext.runtime.sendMessage(message, (response) => {
|
|
||||||
const error = ext.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVisibleInput(input) {
|
|
||||||
if (!(input instanceof HTMLInputElement)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (input.disabled || input.readOnly) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const style = window.getComputedStyle(input);
|
|
||||||
if (style.display === "none" || style.visibility === "hidden") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.offsetParent !== null || style.position === "fixed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRole(rawRole) {
|
|
||||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
|
||||||
case "password":
|
|
||||||
return "password";
|
|
||||||
case "username":
|
|
||||||
return "username";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lowerJoined(values) {
|
|
||||||
return values
|
|
||||||
.map((value) => String(value || "").trim().toLowerCase())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldHintText(input) {
|
|
||||||
if (!input || typeof input !== "object") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
|
|
||||||
return lowerJoined([
|
|
||||||
input.getAttribute?.("type"),
|
|
||||||
input.getAttribute?.("name"),
|
|
||||||
input.getAttribute?.("id"),
|
|
||||||
input.autocomplete,
|
|
||||||
input.getAttribute?.("autocomplete"),
|
|
||||||
input.getAttribute?.("placeholder"),
|
|
||||||
input.getAttribute?.("aria-label"),
|
|
||||||
...labels
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function textLikeInputType(type) {
|
|
||||||
switch (String(type || "").toLowerCase()) {
|
|
||||||
case "":
|
|
||||||
case "text":
|
|
||||||
case "email":
|
|
||||||
case "tel":
|
|
||||||
case "number":
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hintMatches(text, patterns) {
|
|
||||||
return patterns.some((pattern) => pattern.test(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeHintText(scope) {
|
|
||||||
const isDocumentScope = typeof document !== "undefined" && scope === document;
|
|
||||||
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const attrText = isDocumentScope ? "" : lowerJoined([
|
|
||||||
scope.getAttribute?.("id"),
|
|
||||||
scope.getAttribute?.("name"),
|
|
||||||
scope.getAttribute?.("class"),
|
|
||||||
scope.getAttribute?.("action"),
|
|
||||||
scope.getAttribute?.("aria-label")
|
|
||||||
]);
|
|
||||||
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((element) => element.textContent || ""));
|
|
||||||
return lowerJoined([attrText, headingText]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAuthFlowSignals(usernameInput, scope) {
|
|
||||||
if (usernameInput) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return hintMatches(scopeHintText(scope), authScopePatterns);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usernameHintPatterns = [
|
|
||||||
/\buser(name|id)?\b/,
|
|
||||||
/\blog[\s_-]?in\b/,
|
|
||||||
/\bsign[\s_-]?in\b/,
|
|
||||||
/\bemail\b/,
|
|
||||||
/\be-mail\b/,
|
|
||||||
/\baccount\b/,
|
|
||||||
/\bmember\b/,
|
|
||||||
/\bidentifier\b/
|
|
||||||
];
|
|
||||||
|
|
||||||
const nonLoginHintPatterns = [
|
|
||||||
/\bsearch\b/,
|
|
||||||
/\bquery\b/,
|
|
||||||
/\bfilter\b/,
|
|
||||||
/\bcomment\b/,
|
|
||||||
/\bmessage\b/,
|
|
||||||
/\bcontact\b/,
|
|
||||||
/\bcity\b/,
|
|
||||||
/\bstate\b/,
|
|
||||||
/\bpostal\b/,
|
|
||||||
/\bzip\b/,
|
|
||||||
/\bcoupon\b/,
|
|
||||||
/\bpromo\b/,
|
|
||||||
/\bnewsletter\b/,
|
|
||||||
/\bsubscribe\b/
|
|
||||||
];
|
|
||||||
|
|
||||||
const authScopePatterns = [
|
|
||||||
/\blog[\s_-]?in\b/,
|
|
||||||
/\bsign[\s_-]?in\b/,
|
|
||||||
/\bauth\b/,
|
|
||||||
/\bpassword\b/,
|
|
||||||
/\bpasscode\b/,
|
|
||||||
/\b2fa\b/,
|
|
||||||
/\btwo[\s-]?factor\b/,
|
|
||||||
/\bverify\b/,
|
|
||||||
/\baccount\b/
|
|
||||||
];
|
|
||||||
|
|
||||||
function describeFieldRole(input) {
|
|
||||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
|
||||||
if (type === "password") {
|
|
||||||
return "password";
|
|
||||||
}
|
|
||||||
if (!textLikeInputType(type)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const hints = fieldHintText(input);
|
|
||||||
if (!hints) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (hintMatches(hints, nonLoginHintPatterns)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (hintMatches(hints, usernameHintPatterns)) {
|
|
||||||
return "username";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUsernameCandidate(input) {
|
|
||||||
if (!isVisibleInput(input)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return describeFieldRole(input) === "username";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPasswordCandidate(input) {
|
|
||||||
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchFillEvents(input) {
|
|
||||||
if (typeof InputEvent === "function") {
|
|
||||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
|
||||||
} else {
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
}
|
|
||||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setInputValue(input, value) {
|
|
||||||
const prototype = Object.getPrototypeOf(input);
|
|
||||||
const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
|
|
||||||
if (descriptor?.set) {
|
|
||||||
descriptor.set.call(input, value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleInputs(scope) {
|
|
||||||
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFormInputs(anchorInput) {
|
|
||||||
if (anchorInput?.form instanceof HTMLFormElement) {
|
|
||||||
return visibleInputs(anchorInput.form);
|
|
||||||
}
|
|
||||||
return visibleInputs(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstVisiblePassword(scope) {
|
|
||||||
return visibleInputs(scope).find(isPasswordCandidate) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstVisibleUsername(scope) {
|
|
||||||
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authFlowCandidate(anchorInput) {
|
|
||||||
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
|
|
||||||
const scopeInputs = resolveFormInputs(anchorInput);
|
|
||||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
|
|
||||||
if (!passwordInput) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
|
|
||||||
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
usernameInput: associated.usernameInput,
|
|
||||||
passwordInput,
|
|
||||||
anchorInput: anchorInput || passwordInput,
|
|
||||||
scope
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginCandidates() {
|
|
||||||
const candidates = [];
|
|
||||||
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
|
|
||||||
const candidate = authFlowCandidate(passwordInput);
|
|
||||||
if (!candidate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
candidates.push(candidate);
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function associatedFieldsForAnchor(anchorInput) {
|
|
||||||
const scopeInputs = resolveFormInputs(anchorInput);
|
|
||||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
|
||||||
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
|
|
||||||
let usernameInput = usernameInScope[0] || null;
|
|
||||||
if (passwordInput && usernameInScope.length !== 0) {
|
|
||||||
const priorSibling = usernameInScope.find((input) =>
|
|
||||||
typeof input.compareDocumentPosition === "function" &&
|
|
||||||
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
||||||
);
|
|
||||||
usernameInput = priorSibling || usernameInScope[0] || null;
|
|
||||||
}
|
|
||||||
if (!usernameInput) {
|
|
||||||
usernameInput = firstVisibleUsername(document);
|
|
||||||
}
|
|
||||||
return { usernameInput, passwordInput };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFieldDescriptor(input, role) {
|
|
||||||
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
|
||||||
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
|
|
||||||
const scope = form || document;
|
|
||||||
const inputs = visibleInputs(scope);
|
|
||||||
const fieldIndex = inputs.indexOf(input);
|
|
||||||
const forms = Array.from(document.forms || []);
|
|
||||||
return {
|
|
||||||
role: normalizedRole,
|
|
||||||
formIndex: form ? forms.indexOf(form) : -1,
|
|
||||||
fieldIndex,
|
|
||||||
id: String(input.id || ""),
|
|
||||||
name: String(input.name || ""),
|
|
||||||
autocomplete: String(input.autocomplete || "").toLowerCase()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFieldDescriptor(descriptor) {
|
|
||||||
if (!descriptor || typeof descriptor !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedRole = normalizeRole(descriptor.role);
|
|
||||||
if (!normalizedRole) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const forms = Array.from(document.forms || []);
|
|
||||||
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
|
||||||
const scope = form || document;
|
|
||||||
const inputs = visibleInputs(scope);
|
|
||||||
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
|
|
||||||
const candidate = inputs[descriptor.fieldIndex];
|
|
||||||
if (describeFieldRole(candidate) === normalizedRole) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (descriptor.id) {
|
|
||||||
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
|
|
||||||
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
|
|
||||||
return byID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (descriptor.name) {
|
|
||||||
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
|
|
||||||
if (byName) {
|
|
||||||
return byName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (normalizedRole === "password") {
|
|
||||||
return firstVisiblePassword(scope) || firstVisiblePassword(document);
|
|
||||||
}
|
|
||||||
return firstVisibleUsername(scope) || firstVisibleUsername(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseFillTargets(targetDescriptor) {
|
|
||||||
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
|
|
||||||
const associated = associatedFieldsForAnchor(anchorInput);
|
|
||||||
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
|
|
||||||
return {
|
|
||||||
usernameInput: associated.usernameInput,
|
|
||||||
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
|
|
||||||
anchorInput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
|
|
||||||
return {
|
|
||||||
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
|
|
||||||
passwordInput: associated.passwordInput,
|
|
||||||
anchorInput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
usernameInput: associated.usernameInput,
|
|
||||||
passwordInput: associated.passwordInput,
|
|
||||||
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanLoginFields() {
|
|
||||||
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
|
||||||
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
|
||||||
const explicitRole = describeFieldRole(activeUsable);
|
|
||||||
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
|
|
||||||
const candidates = loginCandidates();
|
|
||||||
const chosen = activeTargets || candidates[0] || null;
|
|
||||||
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
|
|
||||||
const focusRole = explicitRole || describeFieldRole(anchorInput);
|
|
||||||
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
|
|
||||||
const roles = candidates.map((candidate) => {
|
|
||||||
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
|
|
||||||
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
|
|
||||||
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
pageHasLoginForm: Boolean(chosen),
|
|
||||||
usernameInput: chosen?.usernameInput || null,
|
|
||||||
passwordInput: chosen?.passwordInput || null,
|
|
||||||
anchorInput,
|
|
||||||
focusTarget,
|
|
||||||
signature: roles.join("|")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillCredential(credential, targetDescriptor) {
|
|
||||||
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
|
|
||||||
|
|
||||||
if (usernameInput && credential.username) {
|
|
||||||
usernameInput.focus();
|
|
||||||
setInputValue(usernameInput, credential.username);
|
|
||||||
dispatchFillEvents(usernameInput);
|
|
||||||
}
|
|
||||||
if (passwordInput && credential.password) {
|
|
||||||
passwordInput.focus();
|
|
||||||
setInputValue(passwordInput, credential.password);
|
|
||||||
dispatchFillEvents(passwordInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usernameInput && !passwordInput) {
|
|
||||||
return { ok: false, error: "No fillable username or password fields were found." };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function domainLabel(rawURL) {
|
|
||||||
try {
|
|
||||||
return new URL(rawURL).host || "";
|
|
||||||
} catch (_error) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineMatchSummary(match) {
|
|
||||||
const parts = [];
|
|
||||||
if (match.username) {
|
|
||||||
parts.push(match.username);
|
|
||||||
}
|
|
||||||
if (match.url) {
|
|
||||||
const host = domainLabel(match.url);
|
|
||||||
if (host) {
|
|
||||||
parts.push(host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
|
||||||
parts.push(match.path.join(" / "));
|
|
||||||
}
|
|
||||||
return parts.join(" · ") || "No username";
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
|
|
||||||
if (suppressed || idleHidden || !hasTarget) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Boolean(
|
|
||||||
state?.pageHasLoginForm &&
|
|
||||||
(
|
|
||||||
state?.pendingFill ||
|
|
||||||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTestExports = {
|
|
||||||
normalizeRole,
|
|
||||||
describeFieldRole,
|
|
||||||
buildFieldDescriptor,
|
|
||||||
resolveFieldDescriptor,
|
|
||||||
chooseFillTargets,
|
|
||||||
inlineMatchSummary,
|
|
||||||
domainLabel,
|
|
||||||
shouldShowInlineOverlay,
|
|
||||||
fieldHintText,
|
|
||||||
scopeHintText,
|
|
||||||
hasAuthFlowSignals,
|
|
||||||
authFlowCandidate
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNodeTestEnv) {
|
|
||||||
module.exports = contentTestExports;
|
|
||||||
} else {
|
|
||||||
let pageState = {
|
|
||||||
configured: true,
|
|
||||||
success: true,
|
|
||||||
matches: [],
|
|
||||||
pageHasLoginForm: false,
|
|
||||||
pendingFill: false,
|
|
||||||
error: "",
|
|
||||||
focusTarget: null
|
|
||||||
};
|
|
||||||
let chooserOpen = false;
|
|
||||||
let inlineSuppressed = false;
|
|
||||||
let inlineIdleHidden = false;
|
|
||||||
let refreshTimer = null;
|
|
||||||
let idleHideTimer = null;
|
|
||||||
let lastReportedSignature = "";
|
|
||||||
let lastReportedTarget = "";
|
|
||||||
|
|
||||||
const root = document.createElement("div");
|
|
||||||
root.id = "keepassgo-inline-root";
|
|
||||||
root.setAttribute("aria-live", "polite");
|
|
||||||
const shadow = root.attachShadow({ mode: "open" });
|
|
||||||
shadow.innerHTML = `
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
all: initial;
|
|
||||||
}
|
|
||||||
.dock {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2147483647;
|
|
||||||
display: none;
|
|
||||||
min-width: 220px;
|
|
||||||
max-width: min(340px, calc(100vw - 24px));
|
|
||||||
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
|
|
||||||
color: #214f44;
|
|
||||||
}
|
|
||||||
.dock[data-open="true"] .panel {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #c6d8cf;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(180deg, #ffffff, #edf5f0);
|
|
||||||
color: #214f44;
|
|
||||||
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.trigger[data-tone="warning"] {
|
|
||||||
border-color: #e4d0ae;
|
|
||||||
background: linear-gradient(180deg, #fff8ed, #f9edd6);
|
|
||||||
color: #7f4b09;
|
|
||||||
}
|
|
||||||
.trigger[data-tone="error"] {
|
|
||||||
border-color: #e4bcbc;
|
|
||||||
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
|
|
||||||
color: #8c2f2f;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: #4d6d66;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
display: none;
|
|
||||||
margin-top: 8px;
|
|
||||||
border: 1px solid #d7e3dc;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #fffdfa;
|
|
||||||
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.panel-header {
|
|
||||||
padding: 12px 14px 10px;
|
|
||||||
border-bottom: 1px solid #e6efea;
|
|
||||||
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
|
|
||||||
}
|
|
||||||
.panel-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.panel-copy {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #4d6d66;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.match-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.match {
|
|
||||||
display: grid;
|
|
||||||
gap: 3px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid #eef4f0;
|
|
||||||
background: #fffdfa;
|
|
||||||
color: #214f44;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.match:hover,
|
|
||||||
.match:focus-visible {
|
|
||||||
background: #edf5f0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.match strong {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
width: fit-content;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #e7f1eb;
|
|
||||||
color: #255f4a;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.subtle {
|
|
||||||
color: #4d6d66;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
padding: 12px 14px;
|
|
||||||
color: #4d6d66;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="dock" data-open="false">
|
|
||||||
<button type="button" class="trigger" data-tone="ready">
|
|
||||||
<span class="brand">KeePassGO</span>
|
|
||||||
<span class="meta">Checking this form</span>
|
|
||||||
</button>
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div class="panel-title">KeePassGO suggestions</div>
|
|
||||||
<div class="panel-copy">Select a matching login for this field.</div>
|
|
||||||
</div>
|
|
||||||
<div class="match-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dock = shadow.querySelector(".dock");
|
|
||||||
const trigger = shadow.querySelector(".trigger");
|
|
||||||
const meta = shadow.querySelector(".meta");
|
|
||||||
const matchList = shadow.querySelector(".match-list");
|
|
||||||
const panelCopy = shadow.querySelector(".panel-copy");
|
|
||||||
|
|
||||||
function ensureRootMounted() {
|
|
||||||
if (!root.isConnected) {
|
|
||||||
document.documentElement.appendChild(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentTarget() {
|
|
||||||
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideDock() {
|
|
||||||
chooserOpen = false;
|
|
||||||
dock.style.display = "none";
|
|
||||||
dock.dataset.open = "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearIdleHideTimer() {
|
|
||||||
if (idleHideTimer !== null) {
|
|
||||||
clearTimeout(idleHideTimer);
|
|
||||||
idleHideTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshInlineLifetime(shouldShow) {
|
|
||||||
clearIdleHideTimer();
|
|
||||||
if (!shouldShow || chooserOpen || pageState.pendingFill) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
idleHideTimer = window.setTimeout(() => {
|
|
||||||
inlineIdleHidden = true;
|
|
||||||
hideDock();
|
|
||||||
}, 15000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionDock() {
|
|
||||||
const anchor = currentTarget();
|
|
||||||
if (!anchor || dock.style.display === "none") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
|
||||||
const width = Math.min(340, Math.max(220, rect.width));
|
|
||||||
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
|
||||||
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
|
||||||
dock.style.left = `${left}px`;
|
|
||||||
dock.style.top = `${top}px`;
|
|
||||||
dock.style.width = `${width}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMatches() {
|
|
||||||
matchList.textContent = "";
|
|
||||||
if (pageState.pendingFill) {
|
|
||||||
const pending = document.createElement("div");
|
|
||||||
pending.className = "empty";
|
|
||||||
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
|
||||||
matchList.appendChild(pending);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "empty";
|
|
||||||
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
|
||||||
matchList.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of pageState.matches) {
|
|
||||||
const row = document.createElement("button");
|
|
||||||
row.type = "button";
|
|
||||||
row.className = "match";
|
|
||||||
const title = document.createElement("strong");
|
|
||||||
title.textContent = match.title;
|
|
||||||
const summary = document.createElement("span");
|
|
||||||
summary.className = "subtle";
|
|
||||||
summary.textContent = inlineMatchSummary(match);
|
|
||||||
const quality = document.createElement("span");
|
|
||||||
quality.className = "pill";
|
|
||||||
quality.textContent = match.quality || "Candidate";
|
|
||||||
row.appendChild(title);
|
|
||||||
row.appendChild(summary);
|
|
||||||
row.appendChild(quality);
|
|
||||||
row.addEventListener("click", async () => {
|
|
||||||
row.disabled = true;
|
|
||||||
inlineSuppressed = true;
|
|
||||||
hideDock();
|
|
||||||
try {
|
|
||||||
await runtimeSend({
|
|
||||||
type: "keepassgo-fill-entry",
|
|
||||||
entryId: match.id,
|
|
||||||
target: pageState.focusTarget
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
pageState = {
|
|
||||||
...pageState,
|
|
||||||
pendingFill: false,
|
|
||||||
error: "KeePassGO could not fill this page."
|
|
||||||
};
|
|
||||||
renderInlineState();
|
|
||||||
} finally {
|
|
||||||
row.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
matchList.appendChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInlineState() {
|
|
||||||
const target = currentTarget();
|
|
||||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
|
|
||||||
|
|
||||||
if (!shouldShow) {
|
|
||||||
clearIdleHideTimer();
|
|
||||||
hideDock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureRootMounted();
|
|
||||||
dock.style.display = "block";
|
|
||||||
trigger.dataset.tone = pageState.pendingFill ? "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();
|
|
||||||
refreshInlineLifetime(shouldShow);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportFieldState(force) {
|
|
||||||
const scan = scanLoginFields();
|
|
||||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
|
||||||
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
|
|
||||||
inlineIdleHidden = false;
|
|
||||||
}
|
|
||||||
pageState = {
|
|
||||||
...pageState,
|
|
||||||
pageHasLoginForm: scan.pageHasLoginForm,
|
|
||||||
focusTarget: scan.focusTarget
|
|
||||||
};
|
|
||||||
renderInlineState();
|
|
||||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastReportedSignature = scan.signature;
|
|
||||||
lastReportedTarget = nextTarget;
|
|
||||||
void runtimeSend({
|
|
||||||
type: "keepassgo-page-ready",
|
|
||||||
force: Boolean(force),
|
|
||||||
pageHasLoginForm: scan.pageHasLoginForm,
|
|
||||||
focusTarget: scan.focusTarget,
|
|
||||||
signature: scan.signature
|
|
||||||
}).then((response) => {
|
|
||||||
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
|
|
||||||
pageState = {
|
|
||||||
...pageState,
|
|
||||||
...response,
|
|
||||||
pageHasLoginForm: Boolean(response.pageHasLoginForm),
|
|
||||||
focusTarget: response.focusTarget || pageState.focusTarget
|
|
||||||
};
|
|
||||||
renderInlineState();
|
|
||||||
}
|
|
||||||
}).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRefresh(force) {
|
|
||||||
if (refreshTimer !== null) {
|
|
||||||
clearTimeout(refreshTimer);
|
|
||||||
}
|
|
||||||
refreshTimer = window.setTimeout(() => {
|
|
||||||
refreshTimer = null;
|
|
||||||
reportFieldState(force);
|
|
||||||
}, force ? 0 : 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger.addEventListener("click", () => {
|
|
||||||
chooserOpen = !chooserOpen;
|
|
||||||
renderInlineState();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("focusin", () => {
|
|
||||||
scheduleRefresh(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("input", () => {
|
|
||||||
scheduleRefresh(false);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
document.addEventListener("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);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
const test = require("node:test");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
|
|
||||||
const content = require("./content.js");
|
|
||||||
|
|
||||||
test("inlineMatchSummary includes username, host, and path context", () => {
|
|
||||||
const summary = content.inlineMatchSummary({
|
|
||||||
username: "dannyocean",
|
|
||||||
url: "https://vault.example.invalid/login",
|
|
||||||
path: ["Root", "Crew"]
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("domainLabel tolerates invalid URLs", () => {
|
|
||||||
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
|
|
||||||
assert.equal(content.domainLabel("not-a-url"), "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("describeFieldRole only treats explicit account fields as usernames", () => {
|
|
||||||
const loginField = {
|
|
||||||
autocomplete: "username",
|
|
||||||
labels: [],
|
|
||||||
getAttribute(name) {
|
|
||||||
const attrs = {
|
|
||||||
type: "email",
|
|
||||||
id: "crew-email",
|
|
||||||
name: "email",
|
|
||||||
placeholder: "Email address",
|
|
||||||
"aria-label": "Email address"
|
|
||||||
};
|
|
||||||
return attrs[name] || "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const searchField = {
|
|
||||||
autocomplete: "",
|
|
||||||
labels: [],
|
|
||||||
getAttribute(name) {
|
|
||||||
const attrs = {
|
|
||||||
type: "text",
|
|
||||||
id: "site-search",
|
|
||||||
name: "query",
|
|
||||||
placeholder: "Search casino news",
|
|
||||||
"aria-label": "Search"
|
|
||||||
};
|
|
||||||
return attrs[name] || "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(content.describeFieldRole(loginField), "username");
|
|
||||||
assert.equal(content.describeFieldRole(searchField), "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
|
|
||||||
const genericScope = {
|
|
||||||
getAttribute() {
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
querySelectorAll() {
|
|
||||||
return [{ textContent: "Confirm shipment" }];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const signInScope = {
|
|
||||||
getAttribute(name) {
|
|
||||||
const attrs = {
|
|
||||||
id: "signin-panel",
|
|
||||||
name: "signin",
|
|
||||||
action: "/session"
|
|
||||||
};
|
|
||||||
return attrs[name] || "";
|
|
||||||
},
|
|
||||||
querySelectorAll() {
|
|
||||||
return [{ textContent: "Sign in to the Bellagio vault" }];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
|
|
||||||
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
|
|
||||||
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
|
||||||
const state = {
|
|
||||||
pageHasLoginForm: true,
|
|
||||||
configured: true,
|
|
||||||
success: true,
|
|
||||||
status: { locked: false },
|
|
||||||
matches: [{ id: "vault-console" }],
|
|
||||||
pendingFill: false
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
|
||||||
const state = {
|
|
||||||
pageHasLoginForm: true,
|
|
||||||
configured: true,
|
|
||||||
success: true,
|
|
||||||
status: { locked: false },
|
|
||||||
matches: [{ id: "rusty-ryan" }],
|
|
||||||
pendingFill: false
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"manifest_version": 3,
|
|
||||||
"name": "KeePassGO Browser",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
|
||||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
|
||||||
"host_permissions": ["http://*/*", "https://*/*"],
|
|
||||||
"background": {
|
|
||||||
"service_worker": "background.js"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"default_title": "KeePassGO Browser",
|
|
||||||
"default_popup": "popup.html"
|
|
||||||
},
|
|
||||||
"options_ui": {
|
|
||||||
"page": "options.html",
|
|
||||||
"open_in_tab": true
|
|
||||||
},
|
|
||||||
"content_scripts": [
|
|
||||||
{
|
|
||||||
"matches": ["http://*/*", "https://*/*"],
|
|
||||||
"js": ["content.js"],
|
|
||||||
"run_at": "document_idle"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"manifest_version": 2,
|
|
||||||
"name": "KeePassGO Browser",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
|
||||||
"permissions": [
|
|
||||||
"activeTab",
|
|
||||||
"nativeMessaging",
|
|
||||||
"storage",
|
|
||||||
"tabs",
|
|
||||||
"http://*/*",
|
|
||||||
"https://*/*"
|
|
||||||
],
|
|
||||||
"background": {
|
|
||||||
"scripts": ["background.js"]
|
|
||||||
},
|
|
||||||
"browser_action": {
|
|
||||||
"default_title": "KeePassGO Browser",
|
|
||||||
"default_popup": "popup.html"
|
|
||||||
},
|
|
||||||
"options_ui": {
|
|
||||||
"page": "options.html",
|
|
||||||
"open_in_tab": true
|
|
||||||
},
|
|
||||||
"content_scripts": [
|
|
||||||
{
|
|
||||||
"matches": ["http://*/*", "https://*/*"],
|
|
||||||
"js": ["content.js"],
|
|
||||||
"run_at": "document_idle"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"browser_specific_settings": {
|
|
||||||
"gecko": {
|
|
||||||
"id": "browser@keepassgo.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>KeePassGO Browser Settings</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="surface settings">
|
|
||||||
<header class="topbar">
|
|
||||||
<div>
|
|
||||||
<h1>Browser Settings</h1>
|
|
||||||
<p class="subtle">Connect the extension to KeePassGO.</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<form id="settings-form" class="settings-form">
|
|
||||||
<label>
|
|
||||||
<span>API token</span>
|
|
||||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
|
||||||
</label>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
<p id="settings-status" class="subtle"></p>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
<script src="options.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
const extOptions = globalThis.browser ?? globalThis.chrome;
|
|
||||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
|
||||||
|
|
||||||
function runtimeSend(message) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return extOptions.runtime.sendMessage(message);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
extOptions.runtime.sendMessage(message, (response) => {
|
|
||||||
const error = extOptions.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
const response = await runtimeSend({ type: "keepassgo-load-settings" });
|
|
||||||
if (!response?.success) {
|
|
||||||
throw new Error(response?.error || "Could not load settings.");
|
|
||||||
}
|
|
||||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const status = document.getElementById("settings-status");
|
|
||||||
status.textContent = "Saving…";
|
|
||||||
try {
|
|
||||||
const response = await runtimeSend({
|
|
||||||
type: "keepassgo-save-settings",
|
|
||||||
settings: {
|
|
||||||
bearerToken: document.getElementById("bearer-token").value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!response?.success) {
|
|
||||||
throw new Error(response?.error || "Could not save settings.");
|
|
||||||
}
|
|
||||||
status.textContent = "Saved.";
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("settings-form").addEventListener("submit", saveSettings);
|
|
||||||
void loadSettings();
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>KeePassGO Browser</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body class="popup">
|
|
||||||
<main class="surface">
|
|
||||||
<header class="topbar">
|
|
||||||
<div>
|
|
||||||
<h1>KeePassGO</h1>
|
|
||||||
<p id="page-host" class="subtle">Checking current page</p>
|
|
||||||
</div>
|
|
||||||
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
|
||||||
</header>
|
|
||||||
<section id="status-card" class="status-card">
|
|
||||||
<strong id="status-title">Loading</strong>
|
|
||||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
|
||||||
</section>
|
|
||||||
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
|
||||||
<section>
|
|
||||||
<h2>Matches</h2>
|
|
||||||
<div id="matches" class="match-list"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script src="popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
|
||||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
|
||||||
|
|
||||||
function runtimeSend(message) {
|
|
||||||
if (usePromiseAPI) {
|
|
||||||
return extPopup.runtime.sendMessage(message);
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
extPopup.runtime.sendMessage(message, (response) => {
|
|
||||||
const error = extPopup.runtime.lastError;
|
|
||||||
if (error) {
|
|
||||||
reject(new Error(error.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostFromURL(rawURL) {
|
|
||||||
try {
|
|
||||||
return new URL(rawURL).host || rawURL;
|
|
||||||
} catch (_error) {
|
|
||||||
return rawURL || "Current page";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(title, message, tone) {
|
|
||||||
const card = document.getElementById("status-card");
|
|
||||||
card.dataset.tone = tone || "neutral";
|
|
||||||
document.getElementById("status-title").textContent = title;
|
|
||||||
document.getElementById("status-message").textContent = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchSubtitle(match) {
|
|
||||||
const parts = [];
|
|
||||||
if (match.username) {
|
|
||||||
parts.push(match.username);
|
|
||||||
}
|
|
||||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
|
||||||
parts.push(match.path.join(" / "));
|
|
||||||
}
|
|
||||||
return parts.join(" · ") || "No username";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMatches(state) {
|
|
||||||
const root = document.getElementById("matches");
|
|
||||||
const targetTabID = popupTabID();
|
|
||||||
root.textContent = "";
|
|
||||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "subtle";
|
|
||||||
empty.textContent = state.pageHasLoginForm
|
|
||||||
? "No matching entries for this page."
|
|
||||||
: "No login fields detected on this page.";
|
|
||||||
root.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of state.matches) {
|
|
||||||
const row = document.createElement("button");
|
|
||||||
row.type = "button";
|
|
||||||
row.className = "match-row";
|
|
||||||
const main = document.createElement("span");
|
|
||||||
main.className = "match-main";
|
|
||||||
const title = document.createElement("strong");
|
|
||||||
title.textContent = match.title;
|
|
||||||
const subtitle = document.createElement("span");
|
|
||||||
subtitle.className = "subtle";
|
|
||||||
subtitle.textContent = matchSubtitle(match);
|
|
||||||
const quality = document.createElement("span");
|
|
||||||
quality.className = "quality";
|
|
||||||
quality.textContent = match.quality || "";
|
|
||||||
main.appendChild(title);
|
|
||||||
main.appendChild(subtitle);
|
|
||||||
row.appendChild(main);
|
|
||||||
row.appendChild(quality);
|
|
||||||
row.addEventListener("click", async () => {
|
|
||||||
row.disabled = true;
|
|
||||||
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
|
||||||
try {
|
|
||||||
const result = await runtimeSend({
|
|
||||||
type: "keepassgo-fill-entry",
|
|
||||||
entryId: match.id,
|
|
||||||
tabId: targetTabID
|
|
||||||
});
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || "Fill failed.");
|
|
||||||
}
|
|
||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
|
||||||
} finally {
|
|
||||||
row.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.appendChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPageHint(state) {
|
|
||||||
const hint = document.getElementById("page-hint");
|
|
||||||
if (state.pendingFill) {
|
|
||||||
hint.textContent = "Approval is pending in KeePassGO.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
|
|
||||||
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.pageHasLoginForm) {
|
|
||||||
hint.textContent = "KeePassGO checked this login form already.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function popupTabID() {
|
|
||||||
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
|
||||||
if (rawValue === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = Number.parseInt(rawValue, 10);
|
|
||||||
return Number.isInteger(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const state = await runtimeSend({
|
|
||||||
type: "keepassgo-popup-state",
|
|
||||||
force: true,
|
|
||||||
tabId: popupTabID()
|
|
||||||
});
|
|
||||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
|
||||||
renderPageHint(state);
|
|
||||||
|
|
||||||
if (!state.configured) {
|
|
||||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
|
||||||
renderMatches({ matches: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.pendingFill) {
|
|
||||||
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
|
||||||
renderMatches(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.success) {
|
|
||||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
|
||||||
renderMatches(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.status?.locked) {
|
|
||||||
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
|
||||||
renderMatches(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
|
||||||
if (!state.pageHasLoginForm) {
|
|
||||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
|
||||||
} else if (count === 0) {
|
|
||||||
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
|
||||||
} else {
|
|
||||||
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
|
|
||||||
}
|
|
||||||
renderMatches(state);
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
|
||||||
renderMatches({ matches: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main();
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--ink: #214f44;
|
|
||||||
--ink-soft: #4d6d66;
|
|
||||||
--surface: #fffdfa;
|
|
||||||
--surface-2: #f2f7f3;
|
|
||||||
--line: #d7e3dc;
|
|
||||||
--accent: #255f4a;
|
|
||||||
--accent-soft: #dfeee6;
|
|
||||||
--warn: #9f5f0e;
|
|
||||||
--error: #9f2f2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
|
||||||
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.popup {
|
|
||||||
min-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtle {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--surface);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card[data-tone="ready"] {
|
|
||||||
border-color: #c5dccf;
|
|
||||||
background: var(--accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card[data-tone="warning"] {
|
|
||||||
border-color: #e4d0ae;
|
|
||||||
background: #fbf4e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card[data-tone="error"] {
|
|
||||||
border-color: #e4bcbc;
|
|
||||||
background: #fcf1f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-hint {
|
|
||||||
margin: -6px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-row,
|
|
||||||
button,
|
|
||||||
.link-button {
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-row {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-row:hover,
|
|
||||||
button:hover,
|
|
||||||
.link-button:hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--ink);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
.link-button {
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: end;
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package buildapk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultSDKRoot = "/opt/android-sdk"
|
|
||||||
DefaultNDKRoot = "/opt/android-ndk"
|
|
||||||
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
|
||||||
DefaultAppID = "org.julianfamily.keepassgo"
|
|
||||||
DefaultAPKOut = "build/keepassgo.apk"
|
|
||||||
DefaultVersion = "0.1.0.1"
|
|
||||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
|
||||||
DefaultMinSDK = "28"
|
|
||||||
DefaultTargetSDK = "35"
|
|
||||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
SDKRoot string
|
|
||||||
NDKRoot string
|
|
||||||
JavaHome string
|
|
||||||
AppID string
|
|
||||||
APKOut string
|
|
||||||
Version string
|
|
||||||
Ldflags string
|
|
||||||
MinSDK string
|
|
||||||
TargetSDK string
|
|
||||||
IconPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
|
||||||
return Config{
|
|
||||||
SDKRoot: DefaultSDKRoot,
|
|
||||||
NDKRoot: DefaultNDKRoot,
|
|
||||||
JavaHome: DefaultJavaHome,
|
|
||||||
AppID: DefaultAppID,
|
|
||||||
APKOut: DefaultAPKOut,
|
|
||||||
Version: DefaultVersion,
|
|
||||||
Ldflags: DefaultLdflags,
|
|
||||||
MinSDK: DefaultMinSDK,
|
|
||||||
TargetSDK: DefaultTargetSDK,
|
|
||||||
IconPath: DefaultIconPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) GogioArgs() []string {
|
|
||||||
return []string{
|
|
||||||
"-target", "android",
|
|
||||||
"-buildmode", "exe",
|
|
||||||
"-appid", c.AppID,
|
|
||||||
"-ldflags", c.Ldflags,
|
|
||||||
"-o", c.APKOut,
|
|
||||||
"-version", c.Version,
|
|
||||||
"-minsdk", c.MinSDK,
|
|
||||||
"-targetsdk", c.TargetSDK,
|
|
||||||
"-icon", c.IconPath,
|
|
||||||
"./cmd/keepassgo",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) Validate() error {
|
|
||||||
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
|
|
||||||
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
|
|
||||||
}
|
|
||||||
if !isDir(c.SDKRoot) {
|
|
||||||
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
|
|
||||||
}
|
|
||||||
if !isDir(c.NDKRoot) {
|
|
||||||
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
|
|
||||||
}
|
|
||||||
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
|
|
||||||
return fmt.Errorf("Android SDK cmdline-tools are missing")
|
|
||||||
}
|
|
||||||
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
|
|
||||||
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
|
|
||||||
}
|
|
||||||
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
|
|
||||||
return fmt.Errorf("Android build-tools are missing")
|
|
||||||
}
|
|
||||||
if !isFile(c.IconPath) {
|
|
||||||
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDir(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
return err == nil && info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExecutable(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil || info.IsDir() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return info.Mode()&0o111 != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFile(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
return err == nil && !info.IsDir()
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package buildapk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDefaultConfigGogioArgs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
want := []string{
|
|
||||||
"-target", "android",
|
|
||||||
"-buildmode", "exe",
|
|
||||||
"-appid", DefaultAppID,
|
|
||||||
"-ldflags", DefaultLdflags,
|
|
||||||
"-o", DefaultAPKOut,
|
|
||||||
"-version", DefaultVersion,
|
|
||||||
"-minsdk", DefaultMinSDK,
|
|
||||||
"-targetsdk", DefaultTargetSDK,
|
|
||||||
"-icon", DefaultIconPath,
|
|
||||||
"./cmd/keepassgo",
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
|
||||||
t.Fatalf("GogioArgs() = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
sdkRoot := filepath.Join(root, "sdk")
|
|
||||||
ndkRoot := filepath.Join(root, "ndk")
|
|
||||||
javaHome := filepath.Join(root, "java")
|
|
||||||
|
|
||||||
mkdirAll(t, filepath.Join(javaHome, "bin"))
|
|
||||||
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
|
|
||||||
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
|
|
||||||
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
|
|
||||||
mkdirAll(t, ndkRoot)
|
|
||||||
|
|
||||||
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
|
|
||||||
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
|
|
||||||
|
|
||||||
cfg := Config{
|
|
||||||
SDKRoot: sdkRoot,
|
|
||||||
NDKRoot: ndkRoot,
|
|
||||||
JavaHome: javaHome,
|
|
||||||
AppID: DefaultAppID,
|
|
||||||
APKOut: DefaultAPKOut,
|
|
||||||
Version: DefaultVersion,
|
|
||||||
Ldflags: DefaultLdflags,
|
|
||||||
MinSDK: DefaultMinSDK,
|
|
||||||
TargetSDK: DefaultTargetSDK,
|
|
||||||
IconPath: filepath.Join(root, "icon.png"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
t.Fatalf("Validate() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
cfg := Config{
|
|
||||||
SDKRoot: filepath.Join(root, "missing-sdk"),
|
|
||||||
NDKRoot: filepath.Join(root, "missing-ndk"),
|
|
||||||
JavaHome: filepath.Join(root, "missing-java"),
|
|
||||||
AppID: DefaultAppID,
|
|
||||||
APKOut: DefaultAPKOut,
|
|
||||||
Version: DefaultVersion,
|
|
||||||
Ldflags: DefaultLdflags,
|
|
||||||
MinSDK: DefaultMinSDK,
|
|
||||||
TargetSDK: DefaultTargetSDK,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err == nil {
|
|
||||||
t.Fatal("Validate() error = nil, want missing prerequisite error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mkdirAll(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
||||||
t.Fatalf("MkdirAll(%q) error = %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeExecutable(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
||||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,14 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
systemclipboard "github.com/atotto/clipboard"
|
systemclipboard "github.com/atotto/clipboard"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||||
var ErrWriteFailed = errors.New("clipboard write failed")
|
|
||||||
|
|
||||||
type Target string
|
type Target string
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.writer().WriteText(content); err != nil {
|
if err := s.writer().WriteText(content); err != nil {
|
||||||
return writeError{err: err}
|
return fmt.Errorf("write clipboard text: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -52,10 +52,6 @@ func (s Service) writer() Writer {
|
|||||||
return systemWriter{}
|
return systemWriter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteText(text string) error {
|
|
||||||
return systemWriter{}.WriteText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
|
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
|
||||||
for _, entry := range model.Entries {
|
for _, entry := range model.Entries {
|
||||||
if entry.ID == entryID {
|
if entry.ID == entryID {
|
||||||
@@ -83,19 +79,3 @@ type systemWriter struct{}
|
|||||||
func (systemWriter) WriteText(text string) error {
|
func (systemWriter) WriteText(text string) error {
|
||||||
return systemclipboard.WriteAll(text)
|
return systemclipboard.WriteAll(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
type writeError struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e writeError) Error() string {
|
|
||||||
return ErrWriteFailed.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e writeError) Unwrap() error {
|
|
||||||
return e.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e writeError) Is(target error) bool {
|
|
||||||
return target == ErrWriteFailed
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||||
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "vault-console",
|
ID: "git-server",
|
||||||
Title: "Vault Console",
|
Title: "Git Server",
|
||||||
Username: "dannyocean",
|
Username: "joejulian",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://git.julianfamily.org",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
|||||||
target Target
|
target Target
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "username", target: TargetUsername, want: "dannyocean"},
|
{name: "username", target: TargetUsername, want: "joejulian"},
|
||||||
{name: "password", target: TargetPassword, want: "token-1"},
|
{name: "password", target: TargetPassword, want: "token-1"},
|
||||||
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
|
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
|||||||
var writer memoryWriter
|
var writer memoryWriter
|
||||||
service := Service{Writer: &writer}
|
service := Service{Writer: &writer}
|
||||||
|
|
||||||
if err := service.Copy(model, "vault-console", tt.target); err != nil {
|
if err := service.Copy(model, "git-server", tt.target); err != nil {
|
||||||
t.Fatalf("Copy() error = %v", err)
|
t.Fatalf("Copy() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,33 +60,14 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
|
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
|
||||||
}
|
}
|
||||||
err = service.Copy(model, "vault-console", Target("unsupported"))
|
err = service.Copy(model, "git-server", Target("unsupported"))
|
||||||
if !errors.Is(err, ErrUnsupportedTarget) {
|
if !errors.Is(err, ErrUnsupportedTarget) {
|
||||||
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
{ID: "vault-console", Password: "token-1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := service.Copy(model, "vault-console", TargetPassword)
|
|
||||||
if !errors.Is(err, ErrWriteFailed) {
|
|
||||||
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
|
||||||
}
|
|
||||||
if err.Error() != ErrWriteFailed.Error() {
|
|
||||||
t.Fatalf("Copy() write error string = %q, want %q", err.Error(), ErrWriteFailed.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type memoryWriter struct {
|
type memoryWriter struct {
|
||||||
content string
|
content string
|
||||||
}
|
}
|
||||||
@@ -95,11 +76,3 @@ func (w *memoryWriter) WriteText(text string) error {
|
|||||||
w.content = text
|
w.content = text
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type failingWriter struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w failingWriter) WriteText(string) error {
|
|
||||||
return w.err
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type bridgeConfig struct {
|
|
||||||
grpcAddr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg := bridgeConfig{
|
|
||||||
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
|
|
||||||
}
|
|
||||||
if len(os.Args) > 1 {
|
|
||||||
switch strings.TrimSpace(os.Args[1]) {
|
|
||||||
case "install-native-host":
|
|
||||||
if err := runInstallNativeHost(os.Args[2:]); err != nil {
|
|
||||||
fail(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
case "status":
|
|
||||||
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
|
|
||||||
fail(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := runNativeMessage(cfg); err != nil {
|
|
||||||
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runInstallNativeHost(args []string) error {
|
|
||||||
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
|
|
||||||
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
|
|
||||||
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
|
|
||||||
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
|
|
||||||
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
|
|
||||||
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
|
|
||||||
outputPath := fs.String("output", "", "native host manifest output path")
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(*binaryPath)
|
|
||||||
if path == "" {
|
|
||||||
resolved, err := defaultBinaryPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path = resolved
|
|
||||||
}
|
|
||||||
resolvedExtensionID := strings.TrimSpace(*extensionID)
|
|
||||||
if resolvedExtensionID == "" {
|
|
||||||
keyValue := strings.TrimSpace(*extensionKey)
|
|
||||||
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
|
|
||||||
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
keyValue = string(data)
|
|
||||||
}
|
|
||||||
if keyValue != "" {
|
|
||||||
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resolvedExtensionID = derivedID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stdout, installed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runStatus(cfg bridgeConfig, args []string) error {
|
|
||||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
|
||||||
token := fs.String("token", "", "KeePassGO API bearer token")
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req := browserbridge.Request{
|
|
||||||
Action: "status",
|
|
||||||
BearerToken: strings.TrimSpace(*token),
|
|
||||||
}
|
|
||||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = conn.Close() }()
|
|
||||||
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
|
|
||||||
enc := json.NewEncoder(os.Stdout)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runNativeMessage(cfg bridgeConfig) error {
|
|
||||||
req, err := browserbridge.ReadRequest(os.Stdin)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
|
||||||
if err != nil {
|
|
||||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
|
||||||
}
|
|
||||||
defer func() { _ = conn.Close() }()
|
|
||||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
|
|
||||||
}
|
|
||||||
|
|
||||||
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
|
|
||||||
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultBinaryPath() (string, error) {
|
|
||||||
return browserbridge.ResolveBridgeBinaryPath("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveGlobalGRPCAddr(args []string) string {
|
|
||||||
addr := grpcaddr.Default(runtime.GOOS)
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
arg := strings.TrimSpace(args[i])
|
|
||||||
switch {
|
|
||||||
case arg == "--grpc-addr" && i+1 < len(args):
|
|
||||||
return strings.TrimSpace(args[i+1])
|
|
||||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripGlobalGRPCAddrFlags(args []string) []string {
|
|
||||||
out := make([]string, 0, len(args))
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
arg := strings.TrimSpace(args[i])
|
|
||||||
switch {
|
|
||||||
case arg == "--grpc-addr" && i+1 < len(args):
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
out = append(out, args[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func fail(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
appui.Main()
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Accessibility Review
|
|
||||||
|
|
||||||
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
|
||||||
|
|
||||||
## What is intentionally supported
|
|
||||||
|
|
||||||
- Keyboard focus is explicit for:
|
|
||||||
- vault search
|
|
||||||
- breadcrumb navigation
|
|
||||||
- entry list selection
|
|
||||||
- detail/editor fields
|
|
||||||
- Focus styling is visible and distinct from the unfocused field treatment.
|
|
||||||
- Common keyboard workflows are covered in-repo by tests for:
|
|
||||||
- tab navigation
|
|
||||||
- list navigation
|
|
||||||
- search focus
|
|
||||||
- new-entry focus transitions
|
|
||||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
|
|
||||||
|
|
||||||
## Current screen-reader boundary
|
|
||||||
|
|
||||||
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
|
|
||||||
- KeePassGO therefore treats screen-reader support as:
|
|
||||||
- label-conscious where the toolkit exposes focusable controls
|
|
||||||
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
|
|
||||||
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
|
|
||||||
|
|
||||||
## Current review result
|
|
||||||
|
|
||||||
- Linux:
|
|
||||||
- keyboard/focus behavior is intentionally supported
|
|
||||||
- visible focus states and control naming are present in code
|
|
||||||
- full Orca-style semantic verification is not something this repo can assert automatically today
|
|
||||||
- Windows:
|
|
||||||
- the same keyboard/focus behavior and explicit labels are present in-app
|
|
||||||
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
|
|
||||||
|
|
||||||
## What KeePassGO should continue doing
|
|
||||||
|
|
||||||
- Keep every major workflow operable without a pointer device.
|
|
||||||
- Add explicit labels for any new focusable control.
|
|
||||||
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
|
|
||||||
- Prefer dialogs and panels that keep keyboard focus predictable.
|
|
||||||
|
|
||||||
## What remains toolkit-limited
|
|
||||||
|
|
||||||
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
|
|
||||||
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Browser Extension
|
|
||||||
|
|
||||||
KeePassGO browser integration uses:
|
|
||||||
|
|
||||||
- the existing local gRPC API in KeePassGO
|
|
||||||
- API tokens for authorization
|
|
||||||
- a tiny native messaging host for browser-to-gRPC transport adaptation
|
|
||||||
|
|
||||||
The browser extension does **not** talk to vault files directly.
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
|
||||||
- The browser extension stores the API token in browser extension storage.
|
|
||||||
- The native messaging host receives the token on each request from the extension.
|
|
||||||
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
|
||||||
- The native messaging host does not persist the token to disk.
|
|
||||||
|
|
||||||
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
|
|
||||||
|
|
||||||
## RPCs Used
|
|
||||||
|
|
||||||
The browser integration uses:
|
|
||||||
|
|
||||||
- `GetSessionStatus`
|
|
||||||
- `FindBrowserLogins`
|
|
||||||
- `GetBrowserCredential`
|
|
||||||
|
|
||||||
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
|
|
||||||
|
|
||||||
## Default Listener
|
|
||||||
|
|
||||||
On desktop KeePassGO listens on a Unix socket by default:
|
|
||||||
|
|
||||||
- primary location: under the user runtime directory
|
|
||||||
- fallback: `/run/user/<uid>` if present
|
|
||||||
- final fallback: a private directory under the system temp directory
|
|
||||||
|
|
||||||
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Native Host
|
|
||||||
|
|
||||||
Build the bridge:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./cmd/keepassgo-browser-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
On Linux desktop builds, KeePassGO now refreshes the user-scoped native messaging manifests on launch. That automatic update always installs the Firefox manifest and also installs Chrome or Chromium manifests when it finds an installed `KeePassGO Browser` extension in that browser profile. The Arch package also ships the extension assets under `/usr/share/keepassgo/browser-extension/`.
|
|
||||||
|
|
||||||
Install a Firefox native messaging manifest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
Install a Chromium native messaging manifest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
|
|
||||||
|
|
||||||
For a fixed Chromium ID:
|
|
||||||
|
|
||||||
1. Keep a stable Chromium extension signing key outside the repo.
|
|
||||||
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
|
|
||||||
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
|
|
||||||
|
|
||||||
## Extension Setup
|
|
||||||
|
|
||||||
Firefox:
|
|
||||||
|
|
||||||
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
|
||||||
2. Open the extension settings page.
|
|
||||||
3. Paste an API token scoped for browser login lookup and credential copy.
|
|
||||||
|
|
||||||
Chromium / Chrome:
|
|
||||||
|
|
||||||
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
|
||||||
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
|
||||||
3. Configure the API token in the extension settings page.
|
|
||||||
|
|
||||||
## Current Browser Flow
|
|
||||||
|
|
||||||
- The extension checks sign-in pages in the background and caches per-tab match state instead of waiting for the popup to be opened first.
|
|
||||||
- The toolbar badge shows when KeePassGO found matches for the current page.
|
|
||||||
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
|
|
||||||
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
|
|
||||||
|
|
||||||
For extension-side regression checks, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
For a reproducible real-browser Chromium validation harness, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make browser-extension-validate
|
|
||||||
```
|
|
||||||
|
|
||||||
That target:
|
|
||||||
|
|
||||||
- validates the Firefox flow by default with a temporary addon install
|
|
||||||
- can also validate Chromium with `make browser-extension-validate BROWSER=chromium`
|
|
||||||
- builds the native messaging bridge
|
|
||||||
- starts a stub KeePassGO gRPC server and a local login page
|
|
||||||
- drives the browser through inline match discovery, approval visibility, and fill completion
|
|
||||||
|
|
||||||
If validation fails, the script preserves its temporary workspace path so the captured HTML, screenshots, logs, and native-host files can be inspected.
|
|
||||||
|
|
||||||
## Required Token Scope
|
|
||||||
|
|
||||||
At minimum, the browser token should have policy rules allowing:
|
|
||||||
|
|
||||||
- `list_entries` for the groups you want the browser to search
|
|
||||||
- `copy_username` for entries the browser may fill
|
|
||||||
- `copy_password` for entries the browser may fill
|
|
||||||
- `copy_url` for entries the browser may confirm against page URL
|
|
||||||
@@ -5,14 +5,9 @@ KeePassGO supports the following KDBX security workflows today:
|
|||||||
- open and save password-only vaults
|
- open and save password-only vaults
|
||||||
- open and save key-file-only vaults
|
- open and save key-file-only vaults
|
||||||
- open and save composite password-plus-key-file vaults
|
- open and save composite password-plus-key-file vaults
|
||||||
- select the active master-key mode in the product UI for create, open, and unlock flows
|
|
||||||
- change an existing session to a new master-key mode before saving
|
|
||||||
- preserve the original opened vault's KDBX format version during save
|
- preserve the original opened vault's KDBX format version during save
|
||||||
- preserve the original opened vault's cipher selection during save
|
- preserve the original opened vault's cipher selection during save
|
||||||
- preserve the original opened vault's KDF selection during save
|
- preserve the original opened vault's KDF selection during save
|
||||||
- choose the cipher family for new vault creation
|
|
||||||
- choose the KDF family for new vault creation
|
|
||||||
- change the cipher family and KDF family for an existing unlocked session before the next save
|
|
||||||
|
|
||||||
What "preserve" means:
|
What "preserve" means:
|
||||||
|
|
||||||
@@ -21,11 +16,11 @@ What "preserve" means:
|
|||||||
|
|
||||||
Current explicit limitations:
|
Current explicit limitations:
|
||||||
|
|
||||||
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
|
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
|
||||||
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
|
- new vault creation still uses the library default KDBX header settings for freshly created databases
|
||||||
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
|
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
|
||||||
|
|
||||||
Practical expectation:
|
Practical expectation:
|
||||||
|
|
||||||
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
|
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
|
||||||
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
|
- KeePassGO does not yet try to be a full advanced database-settings editor
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# Local-First Remote Sync Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
|
||||||
|
|
||||||
- every vault is a local KDBX file first
|
|
||||||
- remote sync is an optional binding on top of that local file
|
|
||||||
- shared remote configuration lives in the vault
|
|
||||||
- user-specific remote credentials live in the vault
|
|
||||||
- app-local state stores only non-secret binding metadata
|
|
||||||
|
|
||||||
Android adds only one platform-specific capability on top of that model:
|
|
||||||
|
|
||||||
- share/import the initial local KDBX file between devices
|
|
||||||
|
|
||||||
## Product Rules
|
|
||||||
|
|
||||||
1. A remote-backed vault must always have a local cache KDBX file.
|
|
||||||
2. Opening a remote-backed vault should open the local KDBX first.
|
|
||||||
3. Shared remote configuration must be stored in the vault, not only in app state.
|
|
||||||
4. Remote credentials must not be stored in plaintext app-local state.
|
|
||||||
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
|
||||||
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
|
||||||
7. Sync must support both manual and automatic modes.
|
|
||||||
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
|
||||||
|
|
||||||
## Target Data Model
|
|
||||||
|
|
||||||
### In-Vault Shared Remote Profile
|
|
||||||
|
|
||||||
Store a reusable remote profile in the vault with fields such as:
|
|
||||||
|
|
||||||
- profile ID
|
|
||||||
- profile name
|
|
||||||
- backend type, initially WebDAV
|
|
||||||
- base URL
|
|
||||||
- remote object path
|
|
||||||
- optional notes or labels
|
|
||||||
- default sync policy, if shared defaults are desirable
|
|
||||||
|
|
||||||
### In-Vault User Credential Binding
|
|
||||||
|
|
||||||
Store user-specific credentials in the vault as normal vault data, referenced by:
|
|
||||||
|
|
||||||
- remote profile ID
|
|
||||||
- credential entry UUID, or another stable internal reference
|
|
||||||
- optional username field override if needed
|
|
||||||
|
|
||||||
The credential entry should contain the actual username/password or token.
|
|
||||||
|
|
||||||
### Local App State
|
|
||||||
|
|
||||||
Persist only non-secret binding state such as:
|
|
||||||
|
|
||||||
- local vault path
|
|
||||||
- selected remote profile ID
|
|
||||||
- selected credential entry reference
|
|
||||||
- sync policy override
|
|
||||||
- last sync metadata
|
|
||||||
- conflict or recovery markers
|
|
||||||
|
|
||||||
## Core Flows
|
|
||||||
|
|
||||||
### Create Or Configure Remote Sync
|
|
||||||
|
|
||||||
1. Open or create a local vault.
|
|
||||||
2. Create or edit a shared remote profile in that vault.
|
|
||||||
3. Create or select a credential entry in that vault.
|
|
||||||
4. Bind the local vault to the selected remote profile and credential reference.
|
|
||||||
5. Choose manual or automatic sync behavior.
|
|
||||||
|
|
||||||
### Reopen Existing Remote-Backed Vault
|
|
||||||
|
|
||||||
1. Open the local vault file from app state.
|
|
||||||
2. Resolve the selected remote profile from vault contents.
|
|
||||||
3. Resolve the credential entry from vault contents.
|
|
||||||
4. Offer or perform sync based on the binding policy.
|
|
||||||
|
|
||||||
### Bootstrap A New Android Device
|
|
||||||
|
|
||||||
1. Share the local KDBX file through Android Sharesheet.
|
|
||||||
2. Import and open that KDBX locally on the new device.
|
|
||||||
3. Select a remote profile stored in the vault.
|
|
||||||
4. Select or create that 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,13 +2,8 @@ module git.julianfamily.org/keepassgo
|
|||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc
|
|
||||||
|
|
||||||
replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.9.0
|
gioui.org v0.9.0
|
||||||
gioui.org/x v0.8.0
|
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||||
@@ -19,9 +14,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||||
gioui.org/cmd v0.8.0 // indirect
|
|
||||||
gioui.org/shader v1.0.8 // indirect
|
gioui.org/shader v1.0.8 // indirect
|
||||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
|
||||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||||
github.com/Antonboom/errname v1.0.0 // indirect
|
github.com/Antonboom/errname v1.0.0 // indirect
|
||||||
@@ -33,7 +26,6 @@ require (
|
|||||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
|
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||||
github.com/akavel/rsrc v0.10.1 // indirect
|
|
||||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||||
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
|
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
|
||||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||||
@@ -78,7 +70,6 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.0.6 // indirect
|
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||||
@@ -216,7 +207,4 @@ require (
|
|||||||
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
|
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool (
|
tool github.com/golangci/golangci-lint/cmd/golangci-lint
|
||||||
gioui.org/cmd/gogio
|
|
||||||
github.com/golangci/golangci-lint/cmd/golangci-lint
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -37,17 +37,11 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
|||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||||
|
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
|
|
||||||
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
|
|
||||||
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc h1:jyfCTx9wk/uLaEMkdKsg491C/kjfbG2EKAVTORhZxHo=
|
|
||||||
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
|
|
||||||
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc h1:p2AaZUAXa/ExPybNyeB05+GjTSZGA9lCfDpWz49IT5Y=
|
|
||||||
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc/go.mod h1:BdI7mF5DCa3kxlo3G93XHL7khtZnk1gu4335pExk8gs=
|
|
||||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
|
||||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
|
||||||
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
||||||
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||||
@@ -72,8 +66,6 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
|
|||||||
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||||
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
|
|
||||||
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||||
@@ -132,12 +124,6 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
|
|||||||
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
|
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
|
||||||
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
||||||
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
||||||
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
|
|
||||||
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
|
||||||
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
|
|
||||||
github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
|
|
||||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
|
||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
@@ -182,8 +168,6 @@ github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4a
|
|||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
|
||||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
@@ -228,14 +212,6 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
|
|||||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
|
||||||
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
|
||||||
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DirtyProvider func() bool
|
|
||||||
|
|
||||||
type Host struct {
|
|
||||||
server *Server
|
|
||||||
grpcServer *grpc.Server
|
|
||||||
listener net.Listener
|
|
||||||
lifecycle lifecycleBackend
|
|
||||||
dirty DirtyProvider
|
|
||||||
mu sync.Mutex
|
|
||||||
lastModel vault.Model
|
|
||||||
started bool
|
|
||||||
listenAddr string
|
|
||||||
socketPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
|
||||||
addr = strings.TrimSpace(addr)
|
|
||||||
if addr == "" || strings.EqualFold(addr, "off") {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
network, endpoint, err := grpcaddr.Parse(addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
listener, socketPath, err := listen(network, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
|
||||||
server := grpc.NewServer()
|
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
|
||||||
|
|
||||||
host := &Host{
|
|
||||||
server: service,
|
|
||||||
grpcServer: server,
|
|
||||||
listener: listener,
|
|
||||||
lifecycle: lifecycle,
|
|
||||||
dirty: dirty,
|
|
||||||
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
|
||||||
socketPath: socketPath,
|
|
||||||
started: true,
|
|
||||||
}
|
|
||||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
|
||||||
_ = listener.Close()
|
|
||||||
server.Stop()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = server.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return host, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Host) Address() string {
|
|
||||||
if h == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return h.listenAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Host) Server() *Server {
|
|
||||||
if h == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return h.server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Host) Stop() error {
|
|
||||||
if h == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
if !h.started {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h.started = false
|
|
||||||
h.grpcServer.Stop()
|
|
||||||
err := h.listener.Close()
|
|
||||||
if errors.Is(err, net.ErrClosed) {
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
if h.socketPath != "" {
|
|
||||||
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
|
||||||
err = removeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Host) SyncFromLifecycle() error {
|
|
||||||
if h == nil || h.lifecycle == nil || h.server == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
model, err := h.lifecycle.Current()
|
|
||||||
locked := false
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
h.lastModel = model
|
|
||||||
case errors.Is(err, session.ErrLocked):
|
|
||||||
locked = true
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dirty := false
|
|
||||||
if h.dirty != nil {
|
|
||||||
dirty = h.dirty()
|
|
||||||
}
|
|
||||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listen(network, endpoint string) (net.Listener, string, error) {
|
|
||||||
if network == "unix" {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
listener, err := net.Listen("unix", endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := os.Chmod(endpoint, 0o600); err != nil {
|
|
||||||
_ = listener.Close()
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return listener, endpoint, nil
|
|
||||||
}
|
|
||||||
listener, err := net.Listen(network, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return listener, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatListenAddress(network, listenerAddr, socketPath string) string {
|
|
||||||
if network == "unix" {
|
|
||||||
return "unix://" + socketPath
|
|
||||||
}
|
|
||||||
return listenerAddr
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lifecycle := &session.Manager{}
|
|
||||||
if err := lifecycle.Create(vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
testAPITokenEntry(t,
|
|
||||||
apitokens.PolicyRule{
|
|
||||||
Effect: apitokens.EffectAllow,
|
|
||||||
Operation: apitokens.OperationManageVault,
|
|
||||||
Resource: apitokens.Resource{
|
|
||||||
Kind: apitokens.ResourceGroup,
|
|
||||||
Path: []string{"Root"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
||||||
},
|
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
|
||||||
t.Fatalf("Create() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StartHost() error = %v", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = host.Stop() }()
|
|
||||||
|
|
||||||
network, endpoint, err := grpcaddr.Parse(host.Address())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Parse(host.Address()) error = %v", err)
|
|
||||||
}
|
|
||||||
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
|
||||||
return net.Dial(network, endpoint)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("grpc.NewClient() error = %v", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = conn.Close() }()
|
|
||||||
|
|
||||||
client := keepassgov1.NewVaultServiceClient(conn)
|
|
||||||
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
|
||||||
}
|
|
||||||
if statusResp.Locked {
|
|
||||||
t.Fatal("GetSessionStatus().Locked = true, want false")
|
|
||||||
}
|
|
||||||
if !statusResp.Dirty {
|
|
||||||
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := lifecycle.Lock(); err != nil {
|
|
||||||
t.Fatalf("Lock() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := host.SyncFromLifecycle(); err != nil {
|
|
||||||
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSessionStatus() after lock error = %v", err)
|
|
||||||
}
|
|
||||||
if !statusResp.Locked {
|
|
||||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartHostServesOverUnixSocket(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
socketDir := t.TempDir()
|
|
||||||
socketPath := socketDir + "/keepassgo.sock"
|
|
||||||
lifecycle := &session.Manager{}
|
|
||||||
if err := lifecycle.Create(vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
testAPITokenEntry(t,
|
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
|
||||||
t.Fatalf("Create() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StartHost() error = %v", err)
|
|
||||||
}
|
|
||||||
if got := host.Address(); got != "unix://"+socketPath {
|
|
||||||
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(socketPath); err != nil {
|
|
||||||
t.Fatalf("Stat(socketPath) error = %v", err)
|
|
||||||
}
|
|
||||||
if err := host.Stop(); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
|
|
||||||
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,234 +0,0 @@
|
|||||||
package apiapproval
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrRequestDenied = errors.New("authorization request denied")
|
|
||||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
|
||||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
|
||||||
ErrRequestNotFound = errors.New("authorization request not found")
|
|
||||||
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Outcome string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OutcomeAllowOnce Outcome = "allow-once"
|
|
||||||
OutcomeDenyOnce Outcome = "deny-once"
|
|
||||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
|
||||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
|
||||||
OutcomeCancel Outcome = "cancel"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Request struct {
|
|
||||||
ID string
|
|
||||||
TokenID string
|
|
||||||
TokenName string
|
|
||||||
ClientName string
|
|
||||||
Operation apitokens.Operation
|
|
||||||
Resource apitokens.Resource
|
|
||||||
RequestedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
Outcome Outcome
|
|
||||||
Rule *apitokens.PolicyRule
|
|
||||||
}
|
|
||||||
|
|
||||||
type Broker struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
pending map[string]*pendingRequest
|
|
||||||
timeout time.Duration
|
|
||||||
now func() time.Time
|
|
||||||
nextID func() string
|
|
||||||
notify func()
|
|
||||||
}
|
|
||||||
|
|
||||||
type pendingRequest struct {
|
|
||||||
request Request
|
|
||||||
done chan Outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
type idGenerator struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
counter int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBroker(timeout time.Duration) *Broker {
|
|
||||||
gen := &idGenerator{}
|
|
||||||
return &Broker{
|
|
||||||
pending: map[string]*pendingRequest{},
|
|
||||||
timeout: timeout,
|
|
||||||
now: func() time.Time {
|
|
||||||
return time.Now().UTC()
|
|
||||||
},
|
|
||||||
nextID: func() string {
|
|
||||||
return gen.Next()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *idGenerator) Next() string {
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
g.counter++
|
|
||||||
return "approval-" + strconv.Itoa(g.counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Broker) Pending() []Request {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
requests := make([]Request, 0, len(b.pending))
|
|
||||||
for _, pending := range b.pending {
|
|
||||||
requests = append(requests, pending.request)
|
|
||||||
}
|
|
||||||
slices.SortFunc(requests, func(a, c Request) int {
|
|
||||||
switch {
|
|
||||||
case a.RequestedAt.Before(c.RequestedAt):
|
|
||||||
return -1
|
|
||||||
case a.RequestedAt.After(c.RequestedAt):
|
|
||||||
return 1
|
|
||||||
case a.ID < c.ID:
|
|
||||||
return -1
|
|
||||||
case a.ID > c.ID:
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Broker) SetChangeNotifier(notify func()) {
|
|
||||||
if b == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
b.notify = notify
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
|
||||||
if b == nil {
|
|
||||||
return Result{}, ErrBrokerNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
pending := &pendingRequest{
|
|
||||||
request: Request{
|
|
||||||
ID: b.nextID(),
|
|
||||||
TokenID: token.ID,
|
|
||||||
TokenName: token.Name,
|
|
||||||
ClientName: token.ClientName,
|
|
||||||
Operation: op,
|
|
||||||
Resource: resource,
|
|
||||||
RequestedAt: b.now(),
|
|
||||||
},
|
|
||||||
done: make(chan Outcome, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
b.pending[pending.request.ID] = pending
|
|
||||||
notify := b.notify
|
|
||||||
b.mu.Unlock()
|
|
||||||
if notify != nil {
|
|
||||||
notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
b.mu.Lock()
|
|
||||||
delete(b.pending, pending.request.ID)
|
|
||||||
notify := b.notify
|
|
||||||
b.mu.Unlock()
|
|
||||||
if notify != nil {
|
|
||||||
notify()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
timer := time.NewTimer(b.timeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case outcome := <-pending.done:
|
|
||||||
result, err := resultForOutcome(pending.request, outcome)
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
case <-timer.C:
|
|
||||||
return Result{}, ErrRequestTimedOut
|
|
||||||
case <-ctx.Done():
|
|
||||||
return Result{}, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
|
|
||||||
b.mu.Lock()
|
|
||||||
pending, ok := b.pending[id]
|
|
||||||
b.mu.Unlock()
|
|
||||||
if !ok {
|
|
||||||
return Request{}, nil, ErrRequestNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, err := RuleFromDecision(pending.request, outcome)
|
|
||||||
if err != nil {
|
|
||||||
return Request{}, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case pending.done <- outcome:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return pending.request, rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
|
|
||||||
rule, err := RuleFromDecision(request, outcome)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
result := Result{Outcome: outcome, Rule: rule}
|
|
||||||
switch outcome {
|
|
||||||
case OutcomeAllowOnce, OutcomeAllowPermanent:
|
|
||||||
return result, nil
|
|
||||||
case OutcomeDenyOnce, OutcomeDenyPermanent:
|
|
||||||
return result, ErrRequestDenied
|
|
||||||
case OutcomeCancel:
|
|
||||||
return result, ErrRequestCanceled
|
|
||||||
default:
|
|
||||||
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
|
|
||||||
switch outcome {
|
|
||||||
case OutcomeAllowPermanent:
|
|
||||||
rule := apitokens.PolicyRule{
|
|
||||||
Effect: apitokens.EffectAllow,
|
|
||||||
Operation: request.Operation,
|
|
||||||
Resource: request.Resource,
|
|
||||||
}
|
|
||||||
return &rule, nil
|
|
||||||
case OutcomeDenyPermanent:
|
|
||||||
rule := apitokens.PolicyRule{
|
|
||||||
Effect: apitokens.EffectDeny,
|
|
||||||
Operation: request.Operation,
|
|
||||||
Resource: request.Resource,
|
|
||||||
}
|
|
||||||
return &rule, nil
|
|
||||||
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
|
|
||||||
return nil, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package apiapproval
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
broker := NewBroker(time.Minute)
|
|
||||||
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
|
||||||
|
|
||||||
resultCh := make(chan Result, 1)
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
|
||||||
resultCh <- result
|
|
||||||
errCh <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
waitForPending(t, broker, 1)
|
|
||||||
pending := broker.Pending()
|
|
||||||
if len(pending) != 1 {
|
|
||||||
t.Fatalf("Pending() len = %d, want 1", len(pending))
|
|
||||||
}
|
|
||||||
if pending[0].TokenID != "token-1" {
|
|
||||||
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
|
|
||||||
t.Fatalf("Resolve(allow once) error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := <-resultCh
|
|
||||||
if err := <-errCh; err != nil {
|
|
||||||
t.Fatalf("Request() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
if result.Outcome != OutcomeAllowOnce {
|
|
||||||
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
|
|
||||||
}
|
|
||||||
if result.Rule != nil {
|
|
||||||
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
|
|
||||||
}
|
|
||||||
if got := broker.Pending(); len(got) != 0 {
|
|
||||||
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
broker := NewBroker(time.Minute)
|
|
||||||
reqDone := make(chan struct{})
|
|
||||||
var result Result
|
|
||||||
var err error
|
|
||||||
go func() {
|
|
||||||
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
|
|
||||||
close(reqDone)
|
|
||||||
}()
|
|
||||||
|
|
||||||
waitForPending(t, broker, 1)
|
|
||||||
pending := broker.Pending()[0]
|
|
||||||
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
|
|
||||||
if resolveErr != nil {
|
|
||||||
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
|
|
||||||
}
|
|
||||||
if request.ID != pending.ID {
|
|
||||||
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
|
|
||||||
}
|
|
||||||
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
|
|
||||||
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-reqDone
|
|
||||||
if !errors.Is(err, ErrRequestDenied) {
|
|
||||||
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
|
|
||||||
}
|
|
||||||
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
|
|
||||||
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
|
|
||||||
}
|
|
||||||
if result.Outcome != OutcomeDenyPermanent {
|
|
||||||
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrokerSupportsCancellation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
broker := NewBroker(time.Minute)
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
|
||||||
errCh <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
waitForPending(t, broker, 1)
|
|
||||||
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
|
|
||||||
t.Fatalf("Resolve(cancel) error = %v", err)
|
|
||||||
}
|
|
||||||
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
|
|
||||||
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
broker := NewBroker(10 * time.Millisecond)
|
|
||||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
|
||||||
if !errors.Is(err, ErrRequestTimedOut) {
|
|
||||||
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
|
|
||||||
}
|
|
||||||
if got := broker.Pending(); len(got) != 0 {
|
|
||||||
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var broker *Broker
|
|
||||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
|
||||||
if !errors.Is(err, ErrBrokerNotConfigured) {
|
|
||||||
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
broker := NewBroker(time.Minute)
|
|
||||||
changes := make(chan int, 4)
|
|
||||||
broker.SetChangeNotifier(func() {
|
|
||||||
changes <- len(broker.Pending())
|
|
||||||
})
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
|
||||||
errCh <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
waitForPending(t, broker, 1)
|
|
||||||
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
|
|
||||||
t.Fatalf("Resolve(allow once) error = %v", err)
|
|
||||||
}
|
|
||||||
if err := <-errCh; err != nil {
|
|
||||||
t.Fatalf("Request() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := []int{<-changes, <-changes}
|
|
||||||
slices.Sort(got)
|
|
||||||
if !slices.Equal(got, []int{0, 1}) {
|
|
||||||
t.Fatalf("change notifications = %v, want [0 1]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if got := len(broker.Pending()); got == want {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("Pending() never reached len %d", want)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package apiaudit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EventType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EventApprovalRequested EventType = "approval_requested"
|
|
||||||
EventApprovalAllowed EventType = "approval_allowed"
|
|
||||||
EventApprovalDenied EventType = "approval_denied"
|
|
||||||
EventApprovalCanceled EventType = "approval_canceled"
|
|
||||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
|
||||||
EventTokenIssued EventType = "token_issued"
|
|
||||||
EventTokenUpdated EventType = "token_updated"
|
|
||||||
EventTokenRotated EventType = "token_rotated"
|
|
||||||
EventTokenDisabled EventType = "token_disabled"
|
|
||||||
EventTokenRevoked EventType = "token_revoked"
|
|
||||||
EventTokenDeleted EventType = "token_deleted"
|
|
||||||
EventAutofillFound EventType = "autofill_found"
|
|
||||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
|
||||||
EventAutofillBlocked EventType = "autofill_blocked"
|
|
||||||
EventAuthRejected EventType = "auth_rejected"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Event struct {
|
|
||||||
Type EventType
|
|
||||||
At time.Time
|
|
||||||
TokenID string
|
|
||||||
TokenName string
|
|
||||||
ClientName string
|
|
||||||
Operation apitokens.Operation
|
|
||||||
Resource apitokens.Resource
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Log struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
max int
|
|
||||||
now func() time.Time
|
|
||||||
events []Event
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(max int) *Log {
|
|
||||||
if max < 1 {
|
|
||||||
max = 1
|
|
||||||
}
|
|
||||||
return &Log{
|
|
||||||
max: max,
|
|
||||||
now: func() time.Time {
|
|
||||||
return time.Now().UTC()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Log) Record(event Event) {
|
|
||||||
if l == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
if event.At.IsZero() {
|
|
||||||
event.At = l.now()
|
|
||||||
}
|
|
||||||
l.events = append([]Event{event}, l.events...)
|
|
||||||
if len(l.events) > l.max {
|
|
||||||
l.events = l.events[:l.max]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Log) Events() []Event {
|
|
||||||
if l == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
return slices.Clone(l.events)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package apiaudit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := New(2)
|
|
||||||
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
|
||||||
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
|
|
||||||
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
|
|
||||||
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
|
|
||||||
|
|
||||||
events := log.Events()
|
|
||||||
if len(events) != 2 {
|
|
||||||
t.Fatalf("len(Events()) = %d, want 2", len(events))
|
|
||||||
}
|
|
||||||
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
|
|
||||||
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogPreservesRecordedMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := New(5)
|
|
||||||
log.Record(Event{
|
|
||||||
Type: EventApprovalRequested,
|
|
||||||
TokenID: "token-1",
|
|
||||||
TokenName: "CLI",
|
|
||||||
ClientName: "grpc-cli",
|
|
||||||
Operation: apitokens.OperationListEntries,
|
|
||||||
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
|
|
||||||
Message: "prompted for access",
|
|
||||||
})
|
|
||||||
|
|
||||||
events := log.Events()
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
|
||||||
}
|
|
||||||
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
|
|
||||||
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogStoresAutofillEventTypes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := New(5)
|
|
||||||
log.Record(Event{
|
|
||||||
Type: EventAutofillAmbiguous,
|
|
||||||
TokenName: "Browser Extension",
|
|
||||||
Message: "multiple matches for example.com",
|
|
||||||
})
|
|
||||||
|
|
||||||
events := log.Events()
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
|
||||||
}
|
|
||||||
if events[0].Type != EventAutofillAmbiguous {
|
|
||||||
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
package apitokens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
EntryTypeAPIToken = "api-token"
|
|
||||||
|
|
||||||
FieldType = "KeePassGO-Type"
|
|
||||||
FieldTokenID = "KeePassGO-API-Token-ID"
|
|
||||||
FieldClientName = "KeePassGO-API-Client-Name"
|
|
||||||
FieldCreatedAt = "KeePassGO-API-Created-At"
|
|
||||||
FieldExpiresAt = "KeePassGO-API-Expires-At"
|
|
||||||
FieldDisabled = "KeePassGO-API-Disabled"
|
|
||||||
FieldRevokedAt = "KeePassGO-API-Revoked-At"
|
|
||||||
FieldSecretHash = "KeePassGO-API-Secret-Hash"
|
|
||||||
FieldPolicies = "KeePassGO-API-Policies"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNotAToken = errors.New("entry is not an api token")
|
|
||||||
ErrInvalidToken = errors.New("invalid api token")
|
|
||||||
ErrExpiredToken = errors.New("expired api token")
|
|
||||||
ErrDisabledToken = errors.New("disabled api token")
|
|
||||||
ErrTokenNotFound = errors.New("api token not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
var EntryPath = []string{"Root", "API Tokens"}
|
|
||||||
|
|
||||||
type Effect string
|
|
||||||
type Operation string
|
|
||||||
type ResourceKind string
|
|
||||||
type Decision string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EffectAllow Effect = "allow"
|
|
||||||
EffectDeny Effect = "deny"
|
|
||||||
|
|
||||||
ResourceGroup ResourceKind = "group"
|
|
||||||
ResourceEntry ResourceKind = "entry"
|
|
||||||
|
|
||||||
DecisionAllow Decision = "allow"
|
|
||||||
DecisionDeny Decision = "deny"
|
|
||||||
DecisionPrompt Decision = "prompt"
|
|
||||||
|
|
||||||
OperationListEntries Operation = "list_entries"
|
|
||||||
OperationListGroups Operation = "list_groups"
|
|
||||||
OperationListTemplates Operation = "list_templates"
|
|
||||||
OperationReadEntry Operation = "read_entry"
|
|
||||||
OperationCopyPassword Operation = "copy_password"
|
|
||||||
OperationCopyUsername Operation = "copy_username"
|
|
||||||
OperationCopyURL Operation = "copy_url"
|
|
||||||
OperationMutateEntry Operation = "mutate_entry"
|
|
||||||
OperationMutateGroup Operation = "mutate_group"
|
|
||||||
OperationMutateTemplate Operation = "mutate_template"
|
|
||||||
OperationGeneratePassword Operation = "generate_password"
|
|
||||||
OperationManageVault Operation = "manage_vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Resource struct {
|
|
||||||
Kind ResourceKind `json:"kind"`
|
|
||||||
Path []string `json:"path,omitempty"`
|
|
||||||
EntryID string `json:"entry_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PolicyRule struct {
|
|
||||||
Effect Effect `json:"effect"`
|
|
||||||
Operation Operation `json:"operation"`
|
|
||||||
Resource Resource `json:"resource"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Token struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
ClientName string
|
|
||||||
SecretHash string
|
|
||||||
CreatedAt time.Time
|
|
||||||
ExpiresAt *time.Time
|
|
||||||
RevokedAt *time.Time
|
|
||||||
Disabled bool
|
|
||||||
Policies []PolicyRule
|
|
||||||
}
|
|
||||||
|
|
||||||
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
|
|
||||||
clear, hashed, err := newSecret()
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, "", err
|
|
||||||
}
|
|
||||||
id, _, err := newSecret()
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, "", err
|
|
||||||
}
|
|
||||||
return Token{
|
|
||||||
ID: id,
|
|
||||||
Name: strings.TrimSpace(name),
|
|
||||||
ClientName: strings.TrimSpace(clientName),
|
|
||||||
SecretHash: hashed,
|
|
||||||
CreatedAt: now.UTC(),
|
|
||||||
ExpiresAt: cloneTime(expiresAt),
|
|
||||||
}, clear, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Rotate(token Token, now time.Time) (Token, string, error) {
|
|
||||||
clear, hashed, err := newSecret()
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, "", err
|
|
||||||
}
|
|
||||||
token.SecretHash = hashed
|
|
||||||
token.Disabled = false
|
|
||||||
token.RevokedAt = nil
|
|
||||||
if token.CreatedAt.IsZero() {
|
|
||||||
token.CreatedAt = now.UTC()
|
|
||||||
}
|
|
||||||
return token, clear, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Disable(token Token) Token {
|
|
||||||
token.Disabled = true
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func Revoke(token Token, when time.Time) Token {
|
|
||||||
token.Disabled = true
|
|
||||||
t := when.UTC()
|
|
||||||
token.RevokedAt = &t
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
|
|
||||||
hashed := hashSecret(presentedSecret)
|
|
||||||
for _, token := range tokens {
|
|
||||||
if token.SecretHash != hashed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if token.Disabled || token.RevokedAt != nil {
|
|
||||||
return Token{}, ErrDisabledToken
|
|
||||||
}
|
|
||||||
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
|
|
||||||
return Token{}, ErrExpiredToken
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
return Token{}, ErrInvalidToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func Evaluate(token Token, operation Operation, resource Resource) Decision {
|
|
||||||
decision := DecisionPrompt
|
|
||||||
for _, rule := range token.Policies {
|
|
||||||
if rule.Operation != operation {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !matches(rule.Resource, resource) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rule.Effect == EffectDeny {
|
|
||||||
return DecisionDeny
|
|
||||||
}
|
|
||||||
if rule.Effect == EffectAllow {
|
|
||||||
decision = DecisionAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decision
|
|
||||||
}
|
|
||||||
|
|
||||||
func Entries(model vault.Model) ([]Token, error) {
|
|
||||||
var out []Token
|
|
||||||
for _, entry := range model.Entries {
|
|
||||||
token, ok, err := TokenFromEntry(entry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
out = append(out, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.SortFunc(out, func(a, b Token) int {
|
|
||||||
switch {
|
|
||||||
case a.Name < b.Name:
|
|
||||||
return -1
|
|
||||||
case a.Name > b.Name:
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return strings.Compare(a.ID, b.ID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Find(model vault.Model, id string) (Token, error) {
|
|
||||||
tokens, err := Entries(model)
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, err
|
|
||||||
}
|
|
||||||
for _, token := range tokens {
|
|
||||||
if token.ID == id {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Token{}, ErrTokenNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func Upsert(model *vault.Model, token Token) {
|
|
||||||
model.UpsertEntry(token.Entry(EntryPath))
|
|
||||||
model.CreateGroup([]string{"Root"}, "API Tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
func Delete(model *vault.Model, id string) error {
|
|
||||||
for i, entry := range model.Entries {
|
|
||||||
token, ok, err := TokenFromEntry(entry)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ok && token.ID == id {
|
|
||||||
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ErrTokenNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
|
|
||||||
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
|
||||||
return Token{}, false, nil
|
|
||||||
}
|
|
||||||
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, true, fmt.Errorf("parse created at: %w", err)
|
|
||||||
}
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, raw)
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
|
|
||||||
}
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
var revokedAt *time.Time
|
|
||||||
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, raw)
|
|
||||||
if err != nil {
|
|
||||||
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
|
|
||||||
}
|
|
||||||
revokedAt = &t
|
|
||||||
}
|
|
||||||
policies := []PolicyRule{}
|
|
||||||
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
|
|
||||||
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
|
|
||||||
return Token{}, true, fmt.Errorf("parse policies: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Token{
|
|
||||||
ID: entry.Fields[FieldTokenID],
|
|
||||||
Name: entry.Title,
|
|
||||||
ClientName: entry.Fields[FieldClientName],
|
|
||||||
SecretHash: entry.Fields[FieldSecretHash],
|
|
||||||
CreatedAt: createdAt,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
RevokedAt: revokedAt,
|
|
||||||
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
|
|
||||||
Policies: policies,
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t Token) Entry(path []string) vault.Entry {
|
|
||||||
fields := map[string]string{
|
|
||||||
FieldType: EntryTypeAPIToken,
|
|
||||||
FieldTokenID: t.ID,
|
|
||||||
FieldClientName: t.ClientName,
|
|
||||||
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
|
|
||||||
FieldSecretHash: t.SecretHash,
|
|
||||||
}
|
|
||||||
if t.ExpiresAt != nil {
|
|
||||||
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if t.RevokedAt != nil {
|
|
||||||
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if len(t.Policies) > 0 {
|
|
||||||
data, _ := json.Marshal(t.Policies)
|
|
||||||
fields[FieldPolicies] = string(data)
|
|
||||||
}
|
|
||||||
return vault.Entry{
|
|
||||||
ID: t.ID,
|
|
||||||
Title: t.Name,
|
|
||||||
Username: t.ClientName,
|
|
||||||
Path: slices.Clone(path),
|
|
||||||
Fields: fields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashSecret(secret string) string {
|
|
||||||
sum := sha256.Sum256([]byte(secret))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSecret() (string, string, error) {
|
|
||||||
buf := make([]byte, 24)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", "", fmt.Errorf("generate secret: %w", err)
|
|
||||||
}
|
|
||||||
clear := base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
return clear, hashSecret(clear), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneTime(in *time.Time) *time.Time {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := in.UTC()
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func matches(rule, resource Resource) bool {
|
|
||||||
switch rule.Kind {
|
|
||||||
case ResourceEntry:
|
|
||||||
return rule.EntryID != "" && rule.EntryID == resource.EntryID
|
|
||||||
case ResourceGroup:
|
|
||||||
if len(rule.Path) > len(resource.Path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package apitokens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
|
||||||
token := Token{
|
|
||||||
ID: "token-1",
|
|
||||||
Name: "Browser Connector",
|
|
||||||
ClientName: "browser-extension",
|
|
||||||
SecretHash: "deadbeef",
|
|
||||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
|
||||||
ExpiresAt: &expiresAt,
|
|
||||||
Disabled: true,
|
|
||||||
Policies: []PolicyRule{
|
|
||||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
|
||||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := token.Entry([]string{"Root", "API Tokens"})
|
|
||||||
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
|
||||||
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, ok, err := TokenFromEntry(entry)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TokenFromEntry() error = %v", err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("TokenFromEntry() ok = false, want true")
|
|
||||||
}
|
|
||||||
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
|
|
||||||
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
|
|
||||||
}
|
|
||||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
|
|
||||||
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
|
|
||||||
}
|
|
||||||
if len(got.Policies) != 2 {
|
|
||||||
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
{ID: "entry-1", Title: "Ordinary Entry"},
|
|
||||||
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
|
||||||
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := Entries(model)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Entries() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("len(Entries()) = %d, want 2", len(got))
|
|
||||||
}
|
|
||||||
if got[0].Name != "Browser" || got[1].Name != "CLI" {
|
|
||||||
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{}
|
|
||||||
token := Token{
|
|
||||||
ID: "token-1",
|
|
||||||
Name: "CLI",
|
|
||||||
ClientName: "grpc-cli",
|
|
||||||
SecretHash: "hash-1",
|
|
||||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
|
||||||
}
|
|
||||||
|
|
||||||
Upsert(&model, token)
|
|
||||||
if len(model.Entries) != 1 {
|
|
||||||
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
|
|
||||||
}
|
|
||||||
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
|
|
||||||
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
token.ClientName = "rotated-client"
|
|
||||||
token.Disabled = true
|
|
||||||
Upsert(&model, token)
|
|
||||||
|
|
||||||
got, err := Find(model, "token-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Find() error = %v", err)
|
|
||||||
}
|
|
||||||
if got.ClientName != "rotated-client" || !got.Disabled {
|
|
||||||
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
|
|
||||||
{ID: "entry-1", Title: "Regular Entry"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := Delete(&model, "token-1"); err != nil {
|
|
||||||
t.Fatalf("Delete() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
|
|
||||||
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
|
|
||||||
}
|
|
||||||
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
|
|
||||||
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
|
||||||
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue() error = %v", err)
|
|
||||||
}
|
|
||||||
if token.ID == "" || secret == "" {
|
|
||||||
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
|
|
||||||
}
|
|
||||||
if token.SecretHash == secret {
|
|
||||||
t.Fatal("SecretHash should not equal cleartext secret")
|
|
||||||
}
|
|
||||||
if token.Disabled {
|
|
||||||
t.Fatal("Disabled = true, want false after Issue")
|
|
||||||
}
|
|
||||||
|
|
||||||
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Rotate() error = %v", err)
|
|
||||||
}
|
|
||||||
if rotated.ID != token.ID {
|
|
||||||
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
|
|
||||||
}
|
|
||||||
if newSecret == secret {
|
|
||||||
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
|
|
||||||
}
|
|
||||||
if rotated.SecretHash == token.SecretHash {
|
|
||||||
t.Fatal("Rotate() left SecretHash unchanged")
|
|
||||||
}
|
|
||||||
|
|
||||||
disabled := Disable(rotated)
|
|
||||||
if !disabled.Disabled {
|
|
||||||
t.Fatal("Disable() did not set Disabled")
|
|
||||||
}
|
|
||||||
revoked := Revoke(disabled, now.Add(2*time.Hour))
|
|
||||||
if !revoked.Disabled {
|
|
||||||
t.Fatal("Revoke() should leave token disabled")
|
|
||||||
}
|
|
||||||
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
|
|
||||||
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
|
||||||
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue() error = %v", err)
|
|
||||||
}
|
|
||||||
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue(expired) error = %v", err)
|
|
||||||
}
|
|
||||||
expiredAt := now.Add(-time.Minute)
|
|
||||||
expired.ExpiresAt = &expiredAt
|
|
||||||
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Issue(disabled) error = %v", err)
|
|
||||||
}
|
|
||||||
disabled = Disable(disabled)
|
|
||||||
|
|
||||||
tokens := []Token{expired, disabled, valid}
|
|
||||||
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
|
|
||||||
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
|
|
||||||
}
|
|
||||||
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
|
|
||||||
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
|
|
||||||
}
|
|
||||||
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
|
|
||||||
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
|
|
||||||
}
|
|
||||||
got, err := Authenticate(tokens, secret, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Authenticate(valid) error = %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != valid.ID {
|
|
||||||
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
token := Token{
|
|
||||||
ID: "token-1",
|
|
||||||
Policies: []PolicyRule{
|
|
||||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
|
||||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
|
|
||||||
if decision != DecisionAllow {
|
|
||||||
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
|
|
||||||
}
|
|
||||||
|
|
||||||
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
|
|
||||||
if decision != DecisionDeny {
|
|
||||||
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
|
|
||||||
}
|
|
||||||
|
|
||||||
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
|
|
||||||
if decision != DecisionPrompt {
|
|
||||||
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
token := Token{
|
|
||||||
ID: "token-1",
|
|
||||||
Policies: []PolicyRule{
|
|
||||||
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
|
||||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
|
|
||||||
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
|
|
||||||
if allowed != DecisionAllow || denied != DecisionDeny {
|
|
||||||
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
token := Token{
|
|
||||||
ID: "token-1",
|
|
||||||
Name: "CLI",
|
|
||||||
ClientName: "cli",
|
|
||||||
SecretHash: "hash",
|
|
||||||
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
|
||||||
Policies: []PolicyRule{
|
|
||||||
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
|
|
||||||
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
|
|
||||||
if err != nil || !ok {
|
|
||||||
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
|
|
||||||
}
|
|
||||||
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
|
|
||||||
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
|
|
||||||
}) {
|
|
||||||
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SyncMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SyncModeManual SyncMode = "manual"
|
|
||||||
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RemoteBinding struct {
|
|
||||||
LocalVaultPath string `json:"localVaultPath"`
|
|
||||||
RemoteProfileID string `json:"remoteProfileId"`
|
|
||||||
CredentialEntryID string `json:"credentialEntryId"`
|
|
||||||
SyncMode SyncMode `json:"syncMode,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResolvedRemoteBinding struct {
|
|
||||||
Profile vault.RemoteProfile
|
|
||||||
Credentials vault.Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
type RemoteBindingInput struct {
|
|
||||||
LocalVaultPath string
|
|
||||||
RemoteProfileID string
|
|
||||||
RemoteProfileName string
|
|
||||||
BaseURL string
|
|
||||||
RemotePath string
|
|
||||||
CredentialEntryID string
|
|
||||||
CredentialTitle string
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
CredentialPath []string
|
|
||||||
SyncMode SyncMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
|
||||||
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
|
||||||
if err != nil {
|
|
||||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
|
||||||
}
|
|
||||||
credentials, err := model.EntryByID(b.CredentialEntryID)
|
|
||||||
if err != nil {
|
|
||||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
|
||||||
}
|
|
||||||
return ResolvedRemoteBinding{
|
|
||||||
Profile: profile,
|
|
||||||
Credentials: credentials,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
|
||||||
if model == nil {
|
|
||||||
return RemoteBinding{}, fmt.Errorf("model is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
|
||||||
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
|
||||||
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
|
||||||
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
|
||||||
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
|
||||||
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
|
||||||
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
|
||||||
input.Username = strings.TrimSpace(input.Username)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case input.LocalVaultPath == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
|
||||||
case input.RemoteProfileID == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
|
||||||
case input.BaseURL == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
|
||||||
case input.RemotePath == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
|
||||||
case input.CredentialEntryID == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
|
||||||
case input.Password == "":
|
|
||||||
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.RemoteProfileName == "" {
|
|
||||||
input.RemoteProfileName = input.RemoteProfileID
|
|
||||||
}
|
|
||||||
if input.CredentialTitle == "" {
|
|
||||||
input.CredentialTitle = "Remote Sign-In"
|
|
||||||
}
|
|
||||||
|
|
||||||
model.UpsertRemoteProfile(vault.RemoteProfile{
|
|
||||||
ID: input.RemoteProfileID,
|
|
||||||
Name: input.RemoteProfileName,
|
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
|
||||||
BaseURL: input.BaseURL,
|
|
||||||
Path: input.RemotePath,
|
|
||||||
})
|
|
||||||
model.UpsertEntry(vault.Entry{
|
|
||||||
ID: input.CredentialEntryID,
|
|
||||||
Title: input.CredentialTitle,
|
|
||||||
Username: input.Username,
|
|
||||||
Password: input.Password,
|
|
||||||
URL: input.BaseURL,
|
|
||||||
Path: append([]string(nil), input.CredentialPath...),
|
|
||||||
})
|
|
||||||
|
|
||||||
return RemoteBinding{
|
|
||||||
LocalVaultPath: input.LocalVaultPath,
|
|
||||||
RemoteProfileID: input.RemoteProfileID,
|
|
||||||
CredentialEntryID: input.CredentialEntryID,
|
|
||||||
SyncMode: normalizeSyncMode(input.SyncMode),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
|
||||||
if model == nil {
|
|
||||||
return fmt.Errorf("model is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
|
||||||
return fmt.Errorf("remote profile id is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
|
||||||
return fmt.Errorf("credential entry id is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
|
||||||
model.RemoveEntryByID(binding.CredentialEntryID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSyncMode(mode SyncMode) SyncMode {
|
|
||||||
switch mode {
|
|
||||||
case SyncModeAutomaticOnOpenSave:
|
|
||||||
return SyncModeAutomaticOnOpenSave
|
|
||||||
default:
|
|
||||||
return SyncModeManual
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
{
|
|
||||||
ID: "linuscaldwell-webdav",
|
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
|
||||||
Username: "linuscaldwell",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
Path: []string{"Crew", "Internet"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RemoteProfiles: []vault.RemoteProfile{
|
|
||||||
{
|
|
||||||
ID: "bellagio-webdav",
|
|
||||||
Name: "Bellagio Vault",
|
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
binding := RemoteBinding{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
CredentialEntryID: "linuscaldwell-webdav",
|
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved, err := binding.Resolve(model)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
|
||||||
}
|
|
||||||
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
|
||||||
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
|
||||||
}
|
|
||||||
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
|
||||||
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
|
||||||
}
|
|
||||||
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
|
||||||
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
|
||||||
}
|
|
||||||
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
|
||||||
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{
|
|
||||||
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := (RemoteBinding{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
CredentialEntryID: "missing-creds",
|
|
||||||
}).Resolve(model)
|
|
||||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
|
||||||
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
model.RemoteProfiles = []vault.RemoteProfile{{
|
|
||||||
ID: "bellagio-webdav",
|
|
||||||
Name: "Bellagio Vault",
|
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err = (RemoteBinding{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
CredentialEntryID: "missing-creds",
|
|
||||||
}).Resolve(model)
|
|
||||||
if !errors.Is(err, vault.ErrEntryNotFound) {
|
|
||||||
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
content, err := json.Marshal(RemoteBinding{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
CredentialEntryID: "remote-creds-1",
|
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := string(content)
|
|
||||||
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
|
||||||
if strings.Contains(text, disallowed) {
|
|
||||||
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var model vault.Model
|
|
||||||
|
|
||||||
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
RemoteProfileName: "Bellagio Vault",
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
|
||||||
CredentialEntryID: "remote-creds-1",
|
|
||||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
|
||||||
Username: "linuscaldwell",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
CredentialPath: []string{"Crew", "Internet"},
|
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(model.RemoteProfiles) != 1 {
|
|
||||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
|
||||||
}
|
|
||||||
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
|
||||||
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
credentials, err := model.EntryByID("remote-creds-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
|
||||||
}
|
|
||||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
|
||||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
|
||||||
}
|
|
||||||
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
|
||||||
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
|
||||||
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
|
||||||
}
|
|
||||||
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
|
||||||
t.Fatalf("binding = %#v, want only vault references", binding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
input RemoteBindingInput
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing_local_vault_path",
|
|
||||||
input: RemoteBindingInput{
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
|
||||||
CredentialEntryID: "remote-creds-1",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_remote_base_url",
|
|
||||||
input: RemoteBindingInput{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
|
||||||
CredentialEntryID: "remote-creds-1",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing_credential_entry_id",
|
|
||||||
input: RemoteBindingInput{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var model vault.Model
|
|
||||||
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
|
||||||
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
model := vault.Model{
|
|
||||||
Entries: []vault.Entry{{
|
|
||||||
ID: "remote-creds-1",
|
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
|
||||||
Username: "linuscaldwell",
|
|
||||||
Password: "bellagio-pass-1",
|
|
||||||
}},
|
|
||||||
RemoteProfiles: []vault.RemoteProfile{{
|
|
||||||
ID: "bellagio-webdav",
|
|
||||||
Name: "Bellagio Vault",
|
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := RemoveRemoteBinding(&model, RemoteBinding{
|
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
|
||||||
RemoteProfileID: "bellagio-webdav",
|
|
||||||
CredentialEntryID: "remote-creds-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := len(model.RemoteProfiles); got != 0 {
|
|
||||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
|
||||||
}
|
|
||||||
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
|
||||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,107 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuditQuickFilter struct {
|
|
||||||
Label string
|
|
||||||
Query string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Operations() []apitokens.Operation {
|
|
||||||
return []apitokens.Operation{
|
|
||||||
apitokens.OperationListEntries,
|
|
||||||
apitokens.OperationListGroups,
|
|
||||||
apitokens.OperationListTemplates,
|
|
||||||
apitokens.OperationReadEntry,
|
|
||||||
apitokens.OperationCopyPassword,
|
|
||||||
apitokens.OperationCopyUsername,
|
|
||||||
apitokens.OperationCopyURL,
|
|
||||||
apitokens.OperationMutateEntry,
|
|
||||||
apitokens.OperationMutateGroup,
|
|
||||||
apitokens.OperationMutateTemplate,
|
|
||||||
apitokens.OperationGeneratePassword,
|
|
||||||
apitokens.OperationManageVault,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditDecisionLabel(eventType apiaudit.EventType) string {
|
|
||||||
switch eventType {
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
return "Requested"
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
return "Allowed"
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
return "Denied"
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
return "Canceled"
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
return "Timed Out"
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
return "Auth Rejected"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditOperationLabel(operation apitokens.Operation) string {
|
|
||||||
if strings.TrimSpace(string(operation)) == "" {
|
|
||||||
return "Other"
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(string(operation), "_", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func CompactAuditFilterLabel(label string) string {
|
|
||||||
label = strings.TrimSpace(label)
|
|
||||||
if len(label) <= 22 {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return label[:19] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditEventSearchTerms(event apiaudit.Event) string {
|
|
||||||
parts := []string{
|
|
||||||
string(event.Type),
|
|
||||||
AuditDecisionLabel(event.Type),
|
|
||||||
event.TokenName,
|
|
||||||
event.ClientName,
|
|
||||||
string(event.Operation),
|
|
||||||
AuditOperationLabel(event.Operation),
|
|
||||||
FormatResourcePath(event.Resource.Path),
|
|
||||||
event.Resource.EntryID,
|
|
||||||
event.Message,
|
|
||||||
}
|
|
||||||
switch event.Type {
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
parts = append(parts, "allow approved")
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
parts = append(parts, "deny denied")
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
parts = append(parts, "prompt requested")
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
parts = append(parts, "cancel canceled")
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
parts = append(parts, "timeout timed out")
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
parts = append(parts, "rejected unauthorized")
|
|
||||||
}
|
|
||||||
return strings.ToLower(strings.Join(parts, " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func DisplayResourcePath(path []string) []string {
|
|
||||||
if len(path) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if path[0] == "keepass" {
|
|
||||||
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
|
|
||||||
}
|
|
||||||
return append([]string(nil), path...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FormatResourcePath(path []string) string {
|
|
||||||
return strings.Join(DisplayResourcePath(path), " / ")
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
type Mode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModeLocked Mode = "locked"
|
|
||||||
ModeStatic Mode = "static"
|
|
||||||
ModeEmpty Mode = "empty"
|
|
||||||
ModeEditor Mode = "editor"
|
|
||||||
ModeView Mode = "view"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
|
||||||
switch {
|
|
||||||
case isLocked:
|
|
||||||
return ModeLocked
|
|
||||||
case hasStaticPanel:
|
|
||||||
return ModeStatic
|
|
||||||
case !hasSelectedEntry && !editing:
|
|
||||||
return ModeEmpty
|
|
||||||
case editing:
|
|
||||||
return ModeEditor
|
|
||||||
default:
|
|
||||||
return ModeView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package detail
|
|
||||||
|
|
||||||
type EmptyState struct {
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
type VaultSummary struct {
|
|
||||||
Title string
|
|
||||||
Detail string
|
|
||||||
Context string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttachmentItem struct {
|
|
||||||
Name string
|
|
||||||
Size int
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package editor
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
type Field string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FieldID Field = "id"
|
|
||||||
FieldTitle Field = "title"
|
|
||||||
FieldUsername Field = "username"
|
|
||||||
FieldPassword Field = "password"
|
|
||||||
FieldURL Field = "url"
|
|
||||||
FieldPath Field = "path"
|
|
||||||
FieldTags Field = "tags"
|
|
||||||
FieldPasswordProfile Field = "password-profile"
|
|
||||||
FieldNotes Field = "notes"
|
|
||||||
FieldFields Field = "fields"
|
|
||||||
FieldHistoryIndex Field = "history-index"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Label(field Field) string {
|
|
||||||
switch field {
|
|
||||||
case FieldID:
|
|
||||||
return "ID"
|
|
||||||
case FieldTitle:
|
|
||||||
return "Title"
|
|
||||||
case FieldUsername:
|
|
||||||
return "Username"
|
|
||||||
case FieldPassword:
|
|
||||||
return "Password"
|
|
||||||
case FieldURL:
|
|
||||||
return "URL"
|
|
||||||
case FieldPath:
|
|
||||||
return "Path"
|
|
||||||
case FieldTags:
|
|
||||||
return "Tags"
|
|
||||||
case FieldPasswordProfile:
|
|
||||||
return "Password Profile"
|
|
||||||
case FieldNotes:
|
|
||||||
return "Notes"
|
|
||||||
case FieldFields:
|
|
||||||
return "Custom Fields"
|
|
||||||
case FieldHistoryIndex:
|
|
||||||
return "History Index"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(field), "-", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FocusOrder() []Field {
|
|
||||||
return []Field{
|
|
||||||
FieldID,
|
|
||||||
FieldTitle,
|
|
||||||
FieldUsername,
|
|
||||||
FieldPassword,
|
|
||||||
FieldURL,
|
|
||||||
FieldPath,
|
|
||||||
FieldTags,
|
|
||||||
FieldPasswordProfile,
|
|
||||||
FieldNotes,
|
|
||||||
FieldFields,
|
|
||||||
FieldHistoryIndex,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/widget"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) attachmentInput() (string, []byte, error) {
|
|
||||||
name := strings.TrimSpace(u.attachmentName.Text())
|
|
||||||
if name == "" {
|
|
||||||
return "", nil, fmt.Errorf("attachment name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
path := strings.TrimSpace(u.attachmentPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return "", nil, fmt.Errorf("attachment path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("stat attachment: %w", err)
|
|
||||||
}
|
|
||||||
if info.Size() > maxAttachmentBytes {
|
|
||||||
return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("read attachment: %w", err)
|
|
||||||
}
|
|
||||||
return name, content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadSelectedEntryIntoEditor() {
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.clearGeneratedPasswordDraft()
|
|
||||||
u.selectedHistoryIndex = -1
|
|
||||||
u.historyIndex.SetText("")
|
|
||||||
|
|
||||||
item, ok := u.selectedEntry()
|
|
||||||
if !ok {
|
|
||||||
u.entryID.SetText("")
|
|
||||||
u.entryTitle.SetText("")
|
|
||||||
u.entryUsername.SetText("")
|
|
||||||
u.entryPassword.SetText("")
|
|
||||||
u.entryURL.SetText("")
|
|
||||||
u.entryNotes.SetText("")
|
|
||||||
u.entryTags.SetText("")
|
|
||||||
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
|
|
||||||
u.entryFields.SetText("")
|
|
||||||
u.setCustomFieldRows(nil)
|
|
||||||
u.attachmentName.SetText("")
|
|
||||||
u.attachmentPath.SetText("")
|
|
||||||
u.exportAttachmentPath.SetText("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u.entryID.SetText(item.ID)
|
|
||||||
u.entryTitle.SetText(item.Title)
|
|
||||||
u.entryUsername.SetText(item.Username)
|
|
||||||
u.entryPassword.SetText(item.Password)
|
|
||||||
u.entryURL.SetText(item.URL)
|
|
||||||
u.entryNotes.SetText(item.Notes)
|
|
||||||
u.entryTags.SetText(strings.Join(item.Tags, ", "))
|
|
||||||
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
|
|
||||||
u.entryFields.SetText(marshalFields(item.Fields))
|
|
||||||
u.setCustomFieldRows(item.Fields)
|
|
||||||
u.attachmentName.SetText("")
|
|
||||||
u.attachmentPath.SetText("")
|
|
||||||
u.exportAttachmentPath.SetText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) setCustomFieldRows(fields map[string]string) {
|
|
||||||
u.customFieldKeys = nil
|
|
||||||
u.customFieldValues = nil
|
|
||||||
u.removeCustomFields = nil
|
|
||||||
if len(fields) == 0 {
|
|
||||||
u.appendCustomFieldRow("", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(fields))
|
|
||||||
for key := range fields {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
slices.Sort(keys)
|
|
||||||
for _, key := range keys {
|
|
||||||
u.appendCustomFieldRow(key, fields[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) appendCustomFieldRow(key, value string) {
|
|
||||||
keyEditor := widget.Editor{SingleLine: true, Submit: false}
|
|
||||||
keyEditor.SetText(key)
|
|
||||||
valueEditor := widget.Editor{SingleLine: true, Submit: false}
|
|
||||||
valueEditor.SetText(value)
|
|
||||||
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
|
|
||||||
u.customFieldValues = append(u.customFieldValues, valueEditor)
|
|
||||||
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) removeCustomFieldRow(index int) {
|
|
||||||
if index < 0 || index >= len(u.customFieldKeys) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
|
|
||||||
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
|
|
||||||
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
|
|
||||||
if len(u.customFieldKeys) == 0 {
|
|
||||||
u.appendCustomFieldRow("", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) currentCustomFields() (map[string]string, error) {
|
|
||||||
fields := map[string]string{}
|
|
||||||
for i := range u.customFieldKeys {
|
|
||||||
key := strings.TrimSpace(u.customFieldKeys[i].Text())
|
|
||||||
value := strings.TrimSpace(u.customFieldValues[i].Text())
|
|
||||||
if key == "" && value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if key == "" {
|
|
||||||
return nil, fmt.Errorf("custom field name is required")
|
|
||||||
}
|
|
||||||
fields[key] = value
|
|
||||||
}
|
|
||||||
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
|
|
||||||
return parseFields(u.entryFields.Text())
|
|
||||||
}
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return fields, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) visibleHistory() []vault.Entry {
|
|
||||||
item, ok := u.selectedEntry()
|
|
||||||
if !ok || len(item.History) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return append([]vault.Entry(nil), item.History...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) selectedHistoryEntry() (vault.Entry, bool) {
|
|
||||||
history := u.visibleHistory()
|
|
||||||
if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) {
|
|
||||||
return vault.Entry{}, false
|
|
||||||
}
|
|
||||||
return history[u.selectedHistoryIndex], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) selectHistoryVersion(index int) error {
|
|
||||||
history := u.visibleHistory()
|
|
||||||
if index < 0 || index >= len(history) {
|
|
||||||
return fmt.Errorf("history index %d out of range", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
u.selectedHistoryIndex = index
|
|
||||||
u.historyIndex.SetText(strconv.Itoa(index))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveEntryAction() error {
|
|
||||||
entry, err := u.editorEntry()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.UpsertEntry(entry); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.editingEntry = false
|
|
||||||
u.clearGeneratedPasswordDraft()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) duplicateSelectedEntryAction() error {
|
|
||||||
baseID := strings.TrimSpace(u.state.SelectedEntryID)
|
|
||||||
if baseID == "" {
|
|
||||||
return fmt.Errorf("no entry selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
duplicateID := baseID + "-copy"
|
|
||||||
if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) deleteSelectedEntryAction() error {
|
|
||||||
if err := u.state.DeleteSelectedEntry(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) restoreSelectedRecycleEntryAction() error {
|
|
||||||
id := strings.TrimSpace(u.state.SelectedEntryID)
|
|
||||||
if id == "" {
|
|
||||||
return fmt.Errorf("no recycle-bin entry selected")
|
|
||||||
}
|
|
||||||
if err := u.state.RestoreEntry(id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveTemplateAction() error {
|
|
||||||
entry, err := u.editorEntry()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(entry.Path) == 0 {
|
|
||||||
entry.Path = []string{"Templates"}
|
|
||||||
}
|
|
||||||
if err := u.state.UpsertTemplate(entry); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.editingEntry = false
|
|
||||||
u.clearGeneratedPasswordDraft()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) deleteSelectedTemplateAction() error {
|
|
||||||
id := strings.TrimSpace(u.state.SelectedEntryID)
|
|
||||||
if id == "" {
|
|
||||||
return fmt.Errorf("no template selected")
|
|
||||||
}
|
|
||||||
if err := u.state.DeleteTemplate(id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) createGroupAction() error {
|
|
||||||
u.clearDeleteGroupConfirmation()
|
|
||||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) moveCurrentGroupAction() error {
|
|
||||||
u.clearDeleteGroupConfirmation()
|
|
||||||
if len(u.displayPath()) == 0 {
|
|
||||||
return fmt.Errorf("no current group selected")
|
|
||||||
}
|
|
||||||
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) renameGroupAction() error {
|
|
||||||
u.clearDeleteGroupConfirmation()
|
|
||||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) deleteCurrentGroupAction() error {
|
|
||||||
if !u.deleteGroupPendingConfirmation() {
|
|
||||||
return fmt.Errorf("confirm deleting the empty group first")
|
|
||||||
}
|
|
||||||
if err := u.state.DeleteCurrentGroup(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.clearDeleteGroupConfirmation()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) instantiateSelectedTemplateAction() error {
|
|
||||||
templateID := strings.TrimSpace(u.state.SelectedEntryID)
|
|
||||||
if templateID == "" {
|
|
||||||
return fmt.Errorf("no template selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
entry, err := u.editorEntry()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) addAttachmentAction() error {
|
|
||||||
name, content, err := u.attachmentInput()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.attachmentName.SetText(name)
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) replaceAttachmentAction() error {
|
|
||||||
name, content, err := u.attachmentInput()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.attachmentName.SetText(name)
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) exportAttachmentAction() error {
|
|
||||||
item, ok := u.selectedEntry()
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("no entry selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(u.attachmentName.Text())
|
|
||||||
content, ok := item.Attachments[name]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("attachment not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
exportPath := strings.TrimSpace(u.exportAttachmentPath.Text())
|
|
||||||
if exportPath == "" {
|
|
||||||
return fmt.Errorf("export attachment path is required")
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(exportPath, content, 0o600); err != nil {
|
|
||||||
return fmt.Errorf("write attachment export: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) removeAttachmentAction() error {
|
|
||||||
name := strings.TrimSpace(u.attachmentName.Text())
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("attachment name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) restoreSelectedHistoryAction() error {
|
|
||||||
index, err := u.selectedHistoryVersionIndex()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) selectedHistoryVersionIndex() (int, error) {
|
|
||||||
text := strings.TrimSpace(u.historyIndex.Text())
|
|
||||||
if text != "" {
|
|
||||||
index, err := strconv.Atoi(text)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid history index: %w", err)
|
|
||||||
}
|
|
||||||
return index, nil
|
|
||||||
}
|
|
||||||
if u.selectedHistoryIndex >= 0 {
|
|
||||||
return u.selectedHistoryIndex, nil
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("no history version selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
|
|
||||||
model, err := u.state.Session.Current()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
service := clipboard.Service{Writer: u.clipboardWriter}
|
|
||||||
return service.Copy(model, u.state.SelectedEntryID, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) generatePasswordAction() error {
|
|
||||||
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
password, err := passwords.Generate(profile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.entryPassword.SetText(password)
|
|
||||||
u.generatedPasswordDraft = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) editorEntry() (vault.Entry, error) {
|
|
||||||
path := parsePath(u.entryPath.Text())
|
|
||||||
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
|
|
||||||
path = append([]string{root}, path...)
|
|
||||||
}
|
|
||||||
fields, err := u.currentCustomFields()
|
|
||||||
if err != nil {
|
|
||||||
return vault.Entry{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return vault.Entry{
|
|
||||||
ID: strings.TrimSpace(u.entryID.Text()),
|
|
||||||
Title: strings.TrimSpace(u.entryTitle.Text()),
|
|
||||||
Username: strings.TrimSpace(u.entryUsername.Text()),
|
|
||||||
Password: u.entryPassword.Text(),
|
|
||||||
URL: strings.TrimSpace(u.entryURL.Text()),
|
|
||||||
Notes: strings.TrimSpace(u.entryNotes.Text()),
|
|
||||||
Tags: parseTags(u.entryTags.Text()),
|
|
||||||
Path: path,
|
|
||||||
Fields: fields,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePath(text string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(text, "/") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTags(text string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(text, ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFields(text string) (map[string]string, error) {
|
|
||||||
if strings.TrimSpace(text) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := map[string]string{}
|
|
||||||
for _, line := range strings.Split(text, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, value, ok := strings.Cut(line, "=")
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid field line %q", line)
|
|
||||||
}
|
|
||||||
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
return fields, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalFields(fields map[string]string) string {
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]string, 0, len(fields))
|
|
||||||
for key, value := range fields {
|
|
||||||
lines = append(lines, key+"="+value)
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) clearGeneratedPasswordDraft() {
|
|
||||||
u.generatedPasswordDraft = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) attachmentActionSummary() string {
|
|
||||||
items := u.selectedAttachmentItems()
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "No attachments yet. Add one below to store a file with this entry."
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(u.attachmentName.Text())
|
|
||||||
if name == "" {
|
|
||||||
return "Select an attachment above, then replace it, export it, or remove it below."
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
if item.Name == name {
|
|
||||||
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,335 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
||||||
return u.headerActions(gtx)
|
|
||||||
}
|
|
||||||
if u.shouldShowDesktopWorkingHeader() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.brandMark(gtx, 196, 56)
|
|
||||||
}),
|
|
||||||
layout.Flexed(1, u.headerActions),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
rowDims := cluster.layout(gtx, u)
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
cluster.prepareCompactMenus(gtx, u)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
|
||||||
}
|
|
||||||
return rowDims
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerActionCluster struct {
|
|
||||||
Metrics headerlayout.ActionMetrics
|
|
||||||
SyncMenu layout.Widget
|
|
||||||
MainMenu layout.Widget
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
|
||||||
|
|
||||||
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
|
||||||
|
|
||||||
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
|
||||||
cluster := headerActionCluster{
|
|
||||||
SyncMenu: u.syncMenuWidget(),
|
|
||||||
MainMenu: u.mainMenuWidget(),
|
|
||||||
}
|
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
|
||||||
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
|
||||||
if !u.usesCompactViewport() {
|
|
||||||
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
|
||||||
}
|
|
||||||
return cluster
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
|
|
||||||
rowOps := op.Record(gtx.Ops)
|
|
||||||
c.Metrics.RowDims = c.layoutRow(gtx, u)
|
|
||||||
rowCall := rowOps.Stop()
|
|
||||||
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
|
|
||||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
rowCall.Add(gtx.Ops)
|
|
||||||
return c.Metrics.RowDims
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) activeMenu() layout.Widget {
|
|
||||||
switch {
|
|
||||||
case c.ShowSyncMenu():
|
|
||||||
return c.SyncMenu
|
|
||||||
case c.ShowMainMenu():
|
|
||||||
return c.MainMenu
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
|
||||||
return c.Metrics.SyncDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
c.Metrics.LockDims = btn.Layout(gtx)
|
|
||||||
return c.Metrics.LockDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
|
||||||
return c.Metrics.MainDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
|
|
||||||
compactSurface := headerlayout.DropdownSurface{
|
|
||||||
ContainerWidth: gtx.Constraints.Max.X,
|
|
||||||
LeftInset: u.frameInsetPx,
|
|
||||||
TopInset: u.frameInsetPx,
|
|
||||||
}
|
|
||||||
if c.ShowSyncMenu() {
|
|
||||||
u.phoneSyncMenuVisible = true
|
|
||||||
u.maybeLogHeaderMenuToggle("sync-visible", true)
|
|
||||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
|
|
||||||
u.phoneSyncMenuOrigin = placement.Origin
|
|
||||||
u.phoneSyncMenuSize = placement.Size
|
|
||||||
u.phoneSyncMenuCall = menuCall
|
|
||||||
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
|
|
||||||
}
|
|
||||||
if c.ShowMainMenu() {
|
|
||||||
u.phoneMainMenuVisible = true
|
|
||||||
u.maybeLogHeaderMenuToggle("main-visible", true)
|
|
||||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
|
|
||||||
u.phoneMainMenuOrigin = placement.Origin
|
|
||||||
u.phoneMainMenuSize = placement.Size
|
|
||||||
u.phoneMainMenuCall = menuCall
|
|
||||||
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
|
|
||||||
menu := c.activeMenu()
|
|
||||||
if menu == nil {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
fullWidthGTX := gtx
|
|
||||||
fullWidthGTX.Constraints.Min = image.Point{}
|
|
||||||
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
|
|
||||||
dims := layout.E.Layout(fullWidthGTX, menu)
|
|
||||||
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerButtonBounds struct {
|
|
||||||
SyncPrimary image.Rectangle
|
|
||||||
SyncToggle image.Rectangle
|
|
||||||
Lock image.Rectangle
|
|
||||||
MainMenu image.Rectangle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
|
|
||||||
return headerButtonBounds{
|
|
||||||
SyncPrimary: bounds.SyncPrimary.Add(origin),
|
|
||||||
SyncToggle: bounds.SyncToggle.Add(origin),
|
|
||||||
Lock: bounds.Lock.Add(origin),
|
|
||||||
MainMenu: bounds.MainMenu.Add(origin),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b headerButtonBounds) logLine(mode string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
|
|
||||||
mode,
|
|
||||||
formatHeaderRect(b.SyncPrimary),
|
|
||||||
formatHeaderRect(b.SyncToggle),
|
|
||||||
formatHeaderRect(b.Lock),
|
|
||||||
formatHeaderRect(b.MainMenu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatHeaderRect(rect image.Rectangle) string {
|
|
||||||
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) topRightActionOrder() []string {
|
|
||||||
if u.isVaultLocked() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{"Sync", "Lock", "Menu"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
if u.syncMenuVisibleOnPhone() {
|
|
||||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
|
||||||
defer stack.Pop()
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if u.mainMenuVisibleOnPhone() {
|
|
||||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
|
||||||
defer stack.Pop()
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
if dims.Size.Y == 0 {
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.usesCompactViewport() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
width := gtx.Dp(unit.Dp(widthDP))
|
|
||||||
height := gtx.Dp(unit.Dp(heightDP))
|
|
||||||
if width > gtx.Constraints.Max.X {
|
|
||||||
width = gtx.Constraints.Max.X
|
|
||||||
}
|
|
||||||
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
|
||||||
height = gtx.Constraints.Max.Y
|
|
||||||
}
|
|
||||||
img := widget.Image{
|
|
||||||
Src: src,
|
|
||||||
Fit: widget.Contain,
|
|
||||||
Position: layout.W,
|
|
||||||
Scale: 1.0 / gtx.Metric.PxPerDp,
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min = image.Point{}
|
|
||||||
gtx.Constraints.Max = image.Pt(width, height)
|
|
||||||
return img.Layout(gtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuWidget() layout.Widget {
|
|
||||||
if !u.mainMenuOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return u.mainMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
|
||||||
icon := u.menuIcon
|
|
||||||
if icon == nil {
|
|
||||||
icon = u.settingsIcon
|
|
||||||
}
|
|
||||||
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|
||||||
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
||||||
return headerlayout.MenuActionWidth(gtx, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
||||||
return headerlayout.RightAlignedAction(gtx, width, child)
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AnchoredMenuX(triggerWidth, menuWidth int) int {
|
|
||||||
return triggerWidth - menuWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
|
|
||||||
x := rowOriginX + triggerRightX - menuWidth
|
|
||||||
if x < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if x+menuWidth > containerWidth {
|
|
||||||
return max(0, containerWidth-menuWidth)
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownAnchor struct {
|
|
||||||
TriggerRightX int
|
|
||||||
TriggerBottomY int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a DropdownAnchor) Point() image.Point {
|
|
||||||
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownSurface struct {
|
|
||||||
ContainerWidth int
|
|
||||||
LeftInset int
|
|
||||||
TopInset int
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownPlacement struct {
|
|
||||||
Anchor DropdownAnchor
|
|
||||||
Origin image.Point
|
|
||||||
Size image.Point
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
|
||||||
menuGTX := gtx
|
|
||||||
menuGTX.Constraints.Min = image.Point{}
|
|
||||||
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
|
|
||||||
return menuGTX
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
|
|
||||||
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
|
|
||||||
y := s.TopInset + anchor.TriggerBottomY
|
|
||||||
return image.Pt(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
|
|
||||||
menuGTX := s.MenuConstraints(gtx)
|
|
||||||
menuOps := op.Record(gtx.Ops)
|
|
||||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
|
||||||
menuCall := menuOps.Stop()
|
|
||||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
|
||||||
return DropdownPlacement{
|
|
||||||
Anchor: anchor,
|
|
||||||
Origin: menuOrigin,
|
|
||||||
Size: menuDims.Size,
|
|
||||||
}, menuCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
|
||||||
placement, menuCall := s.Place(gtx, anchor, menu)
|
|
||||||
stack := op.Offset(placement.Origin).Push(gtx.Ops)
|
|
||||||
menuCall.Add(gtx.Ops)
|
|
||||||
stack.Pop()
|
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionMetrics struct {
|
|
||||||
RowOriginX int
|
|
||||||
Spacing int
|
|
||||||
SyncInnerSpacing int
|
|
||||||
RowDims layout.Dimensions
|
|
||||||
SyncDims layout.Dimensions
|
|
||||||
SyncPrimaryDims layout.Dimensions
|
|
||||||
SyncToggleDims layout.Dimensions
|
|
||||||
LockDims layout.Dimensions
|
|
||||||
MainDims layout.Dimensions
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
|
||||||
return DropdownAnchor{
|
|
||||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
|
||||||
TriggerBottomY: m.RowDims.Size.Y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
|
||||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
|
||||||
return DropdownAnchor{
|
|
||||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
|
||||||
TriggerBottomY: m.RowDims.Size.Y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionBounds struct {
|
|
||||||
SyncPrimary image.Rectangle
|
|
||||||
SyncToggle image.Rectangle
|
|
||||||
Lock image.Rectangle
|
|
||||||
MainMenu image.Rectangle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) Bounds() ActionBounds {
|
|
||||||
top := 0
|
|
||||||
syncLeft := m.RowOriginX
|
|
||||||
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
|
|
||||||
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
|
|
||||||
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
|
|
||||||
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
|
|
||||||
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
|
|
||||||
mainLeft := lock.Max.X + m.Spacing
|
|
||||||
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
|
|
||||||
return ActionBounds{
|
|
||||||
SyncPrimary: syncPrimary,
|
|
||||||
SyncToggle: syncToggle,
|
|
||||||
Lock: lock,
|
|
||||||
MainMenu: mainMenu,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
|
||||||
macro := op.Record(gtx.Ops)
|
|
||||||
contentDims := w(measureGTX)
|
|
||||||
_ = macro.Stop()
|
|
||||||
if logger != nil {
|
|
||||||
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
|
|
||||||
}
|
|
||||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
|
||||||
maxWidth := gtx.Constraints.Max.X
|
|
||||||
if maxWidth > 0 && width > maxWidth {
|
|
||||||
width = maxWidth
|
|
||||||
}
|
|
||||||
if width > 0 {
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
}
|
|
||||||
dims := card(gtx, w)
|
|
||||||
if logger != nil {
|
|
||||||
logger("intrinsic-card", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
|
|
||||||
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
||||||
width := 0
|
|
||||||
for _, row := range rows {
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
macro := op.Record(gtx.Ops)
|
|
||||||
dims := row(measureGTX)
|
|
||||||
_ = macro.Stop()
|
|
||||||
if dims.Size.X > width {
|
|
||||||
width = dims.Size.X
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
||||||
if width <= 0 {
|
|
||||||
return child(gtx)
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
return layout.E.Layout(gtx, child)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package header
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
|
||||||
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
|
||||||
if logger != nil {
|
|
||||||
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
|
|
||||||
}
|
|
||||||
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
|
|
||||||
for i, row := range rows {
|
|
||||||
if i > 0 {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
current := row
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
||||||
if logger != nil {
|
|
||||||
logger("rows", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}, card, logger)
|
|
||||||
if logger != nil {
|
|
||||||
logger("card", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
|
|
||||||
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
|
|
||||||
btn := material.IconButton(theme, click, icon, "Menu")
|
|
||||||
if open {
|
|
||||||
btn.Background = accentColor
|
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = selectedColor
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
|
||||||
btn.Size = unit.Dp(18)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
|
|
||||||
spacing := unit.Dp(4)
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
spacing = unit.Dp(3)
|
|
||||||
}
|
|
||||||
var primaryDims layout.Dimensions
|
|
||||||
var toggleDims layout.Dimensions
|
|
||||||
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
|
||||||
return primaryDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
toggleDims = u.syncMenuToggle(gtx)
|
|
||||||
return toggleDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return groupDims, primaryDims, toggleDims
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
|
||||||
if u.syncMenuOpen {
|
|
||||||
btn.Background = accentColor
|
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
|
||||||
btn.Size = unit.Dp(18)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
btn.Size = unit.Dp(16)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(7))
|
|
||||||
}
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuWidget() layout.Widget {
|
|
||||||
if !u.syncMenuOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return u.syncMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
model := u.buildSyncMenuModel()
|
|
||||||
profiles := u.availableRemoteProfiles()
|
|
||||||
credentials := u.availableRemoteCredentialEntries()
|
|
||||||
if len(u.vaultRemoteProfileClicks) < len(profiles) {
|
|
||||||
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
|
|
||||||
}
|
|
||||||
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
|
|
||||||
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
|
|
||||||
}
|
|
||||||
actionRows := u.syncMenuActionRows(model)
|
|
||||||
actionWidth := menuActionWidth(gtx, actionRows)
|
|
||||||
menu := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
|
|
||||||
if reserveWidth <= 0 {
|
|
||||||
return menu(gtx)
|
|
||||||
}
|
|
||||||
return layout.Flex{}.Layout(gtx,
|
|
||||||
layout.Rigid(menu),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
|
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
spacing = gtx.Dp(unit.Dp(8))
|
|
||||||
}
|
|
||||||
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
|
|
||||||
lockOps := op.Record(gtx.Ops)
|
|
||||||
lockDims := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}(measureGTX)
|
|
||||||
_ = lockOps.Stop()
|
|
||||||
|
|
||||||
menuOps := op.Record(gtx.Ops)
|
|
||||||
menuDims := u.mainMenuButtonGroup(measureGTX)
|
|
||||||
_ = menuOps.Stop()
|
|
||||||
|
|
||||||
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if model.ShowShare {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSetupShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowDirectRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
|
||||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
|
||||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
|
||||||
if model.ShowSaveCurrentBinding {
|
|
||||||
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
|
||||||
rows := []layout.FlexChild{}
|
|
||||||
if model.ShowShare {
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
|
|
||||||
if model.ShowRemoteSyncSetupShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowDirectRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
|
|
||||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, click, label)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
|
||||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rows := []layout.FlexChild{
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
}
|
|
||||||
if !model.ShowSelectors {
|
|
||||||
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
|
|
||||||
} else {
|
|
||||||
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
|
|
||||||
}
|
|
||||||
if _, ok := u.selectedVaultRemoteProfile(); ok {
|
|
||||||
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
|
|
||||||
rows = append(rows,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
|
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
|
||||||
summary := model.SavedBindingSummary
|
|
||||||
if !summary.OK {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
|
|
||||||
return []layout.FlexChild{
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
|
||||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
|
||||||
for i, profile := range profiles {
|
|
||||||
i := i
|
|
||||||
profile := profile
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
selected := u.selectedVaultRemoteProfileID == profile.ID
|
|
||||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
for i, entry := range credentials {
|
|
||||||
i := i
|
|
||||||
entry := entry
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
|
|
||||||
label := entry.Title
|
|
||||||
if entry.Username != "" {
|
|
||||||
label += " · " + entry.Username
|
|
||||||
}
|
|
||||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
|
|
||||||
model := syncmodel.MenuModel{
|
|
||||||
HasOpenVault: u.hasOpenVault(),
|
|
||||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
|
||||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
|
||||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
|
||||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
RemotePassword: u.remotePassword.Text(),
|
|
||||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
|
||||||
}
|
|
||||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
|
||||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
|
||||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
|
|
||||||
profile, ok := u.selectedVaultRemoteProfile()
|
|
||||||
if !ok {
|
|
||||||
return syncmodel.MenuBindingSummary{}
|
|
||||||
}
|
|
||||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
|
||||||
if !ok {
|
|
||||||
return syncmodel.MenuBindingSummary{}
|
|
||||||
}
|
|
||||||
credentialLabel := entry.Title
|
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
|
||||||
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
|
||||||
}
|
|
||||||
syncLabel := "Sync manually when you choose Use Remote Sync."
|
|
||||||
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
|
||||||
syncLabel = "Syncs automatically on open and save."
|
|
||||||
}
|
|
||||||
return syncmodel.MenuBindingSummary{
|
|
||||||
ProfileLabel: profile.Name,
|
|
||||||
CredentialLabel: credentialLabel,
|
|
||||||
SyncLabel: syncLabel,
|
|
||||||
OK: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/io/event"
|
|
||||||
"gioui.org/io/key"
|
|
||||||
"gioui.org/layout"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
||||||
)
|
|
||||||
|
|
||||||
type focusID string
|
|
||||||
|
|
||||||
type detailField = editormodel.Field
|
|
||||||
|
|
||||||
const (
|
|
||||||
focusSearch focusID = "search"
|
|
||||||
|
|
||||||
detailFieldID = editormodel.FieldID
|
|
||||||
detailFieldTitle = editormodel.FieldTitle
|
|
||||||
detailFieldUsername = editormodel.FieldUsername
|
|
||||||
detailFieldPassword = editormodel.FieldPassword
|
|
||||||
detailFieldURL = editormodel.FieldURL
|
|
||||||
detailFieldPath = editormodel.FieldPath
|
|
||||||
detailFieldTags = editormodel.FieldTags
|
|
||||||
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
|
||||||
detailFieldNotes = editormodel.FieldNotes
|
|
||||||
detailFieldFields = editormodel.FieldFields
|
|
||||||
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
shortcutSearch = "search"
|
|
||||||
shortcutSave = "save"
|
|
||||||
shortcutLock = "lock"
|
|
||||||
shortcutNewEntry = "new-entry"
|
|
||||||
shortcutCopyUser = "copy-user"
|
|
||||||
shortcutCopyPassword = "copy-password"
|
|
||||||
shortcutCopyURL = "copy-url"
|
|
||||||
)
|
|
||||||
|
|
||||||
func breadcrumbFocusID(index int) focusID {
|
|
||||||
return focusID(fmt.Sprintf("breadcrumb:%d", index))
|
|
||||||
}
|
|
||||||
|
|
||||||
func listFocusID(index int) focusID {
|
|
||||||
return focusID(fmt.Sprintf("list:%d", index))
|
|
||||||
}
|
|
||||||
|
|
||||||
func detailFocusID(field detailField) focusID {
|
|
||||||
return focusID("detail:" + string(field))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) processShortcuts(gtx layout.Context) {
|
|
||||||
event.Op(gtx.Ops, u)
|
|
||||||
for {
|
|
||||||
ev, ok := gtx.Event(
|
|
||||||
key.Filter{Name: "F", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "S", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "L", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "N", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "U", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "P", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "O", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
|
||||||
key.Filter{Name: key.NameLeftArrow},
|
|
||||||
key.Filter{Name: key.NameRightArrow},
|
|
||||||
key.Filter{Name: key.NameUpArrow},
|
|
||||||
key.Filter{Name: key.NameDownArrow},
|
|
||||||
key.Filter{Name: key.NameReturn},
|
|
||||||
key.Filter{Name: key.NameBack},
|
|
||||||
key.Filter{Name: key.NameEscape},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
ke, ok := ev.(key.Event)
|
|
||||||
if !ok || ke.State != key.Press {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
u.handleKeyPress(ke.Name, ke.Modifiers)
|
|
||||||
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
|
||||||
_ = u.handlePhoneBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) performShortcut(name string) error {
|
|
||||||
switch name {
|
|
||||||
case shortcutSearch:
|
|
||||||
u.keyboardFocus = focusSearch
|
|
||||||
return nil
|
|
||||||
case shortcutSave:
|
|
||||||
return u.saveAction()
|
|
||||||
case shortcutLock:
|
|
||||||
return u.lockAction()
|
|
||||||
case shortcutNewEntry:
|
|
||||||
u.state.BeginNewEntry()
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
|
||||||
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
|
||||||
return nil
|
|
||||||
case shortcutCopyUser:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
|
||||||
case shortcutCopyPassword:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
|
||||||
case shortcutCopyURL:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetURL)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
|
||||||
if u.handleShortcutKey(name, modifiers) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if u.isVaultLocked() && name == key.NameReturn {
|
|
||||||
u.startUnlockAction()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
u.startOpenRemoteAction()
|
|
||||||
} else {
|
|
||||||
u.startOpenVaultAction()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case key.NameTab:
|
|
||||||
delta := 1
|
|
||||||
if modifiers.Contain(key.ModShift) {
|
|
||||||
delta = -1
|
|
||||||
}
|
|
||||||
u.moveKeyboardFocus(delta)
|
|
||||||
return true
|
|
||||||
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
|
|
||||||
return u.handleFocusedKey(name)
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) moveKeyboardFocus(delta int) {
|
|
||||||
order := u.focusOrder()
|
|
||||||
if len(order) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
current := canonicalFocusID(u.keyboardFocus)
|
|
||||||
index := 0
|
|
||||||
for i, item := range order {
|
|
||||||
if canonicalFocusID(item) == current {
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
index += delta
|
|
||||||
if index < 0 {
|
|
||||||
index = len(order) - 1
|
|
||||||
}
|
|
||||||
if index >= len(order) {
|
|
||||||
index = 0
|
|
||||||
}
|
|
||||||
u.setKeyboardFocus(order[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) focusOrder() []focusID {
|
|
||||||
if u.isVaultLocked() {
|
|
||||||
return []focusID{detailFocusID(detailFieldPassword)}
|
|
||||||
}
|
|
||||||
order := []focusID{focusSearch}
|
|
||||||
if u.state.Section != appstate.SectionRecycleBin {
|
|
||||||
order = append(order, breadcrumbFocusID(0))
|
|
||||||
}
|
|
||||||
if len(u.visible) > 0 {
|
|
||||||
order = append(order, listFocusID(u.focusedListIndexOrZero()))
|
|
||||||
}
|
|
||||||
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) setKeyboardFocus(id focusID) {
|
|
||||||
u.keyboardFocus = id
|
|
||||||
if strings.HasPrefix(string(id), "list:") {
|
|
||||||
u.focusListIndex(focusIndex(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleFocusedKey(name key.Name) bool {
|
|
||||||
switch {
|
|
||||||
case u.keyboardFocus == focusSearch:
|
|
||||||
if name == key.NameDownArrow && len(u.visible) > 0 {
|
|
||||||
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
|
|
||||||
return u.handleBreadcrumbKey(name)
|
|
||||||
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
|
|
||||||
return u.handleListKey(name)
|
|
||||||
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
|
|
||||||
return u.handleDetailKey(name)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
|
|
||||||
crumbs := u.breadcrumbLabels()
|
|
||||||
if len(crumbs) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
index := focusIndex(u.keyboardFocus)
|
|
||||||
switch name {
|
|
||||||
case key.NameLeftArrow:
|
|
||||||
if index > 0 {
|
|
||||||
u.keyboardFocus = breadcrumbFocusID(index - 1)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameRightArrow:
|
|
||||||
if index < len(crumbs)-1 {
|
|
||||||
u.keyboardFocus = breadcrumbFocusID(index + 1)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameDownArrow:
|
|
||||||
if len(u.visible) > 0 {
|
|
||||||
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameReturn:
|
|
||||||
u.activateBreadcrumb(index)
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleListKey(name key.Name) bool {
|
|
||||||
if len(u.visible) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
index := focusIndex(u.keyboardFocus)
|
|
||||||
switch name {
|
|
||||||
case key.NameUpArrow:
|
|
||||||
if index > 0 {
|
|
||||||
u.setKeyboardFocus(listFocusID(index - 1))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameDownArrow:
|
|
||||||
if index < len(u.visible)-1 {
|
|
||||||
u.setKeyboardFocus(listFocusID(index + 1))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameLeftArrow:
|
|
||||||
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
|
|
||||||
return true
|
|
||||||
case key.NameRightArrow, key.NameReturn:
|
|
||||||
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleDetailKey(name key.Name) bool {
|
|
||||||
fields := detailFocusOrder()
|
|
||||||
index := u.focusedDetailIndex()
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case key.NameUpArrow:
|
|
||||||
if index > 0 {
|
|
||||||
u.keyboardFocus = detailFocusID(fields[index-1])
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameDownArrow:
|
|
||||||
if index < len(fields)-1 {
|
|
||||||
u.keyboardFocus = detailFocusID(fields[index+1])
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case key.NameLeftArrow:
|
|
||||||
if len(u.visible) > 0 {
|
|
||||||
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
|
|
||||||
if !modifiers.Contain(key.ModShortcut) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "F":
|
|
||||||
_ = u.performShortcut(shortcutSearch)
|
|
||||||
case "S":
|
|
||||||
_ = u.performShortcut(shortcutSave)
|
|
||||||
case "L":
|
|
||||||
_ = u.performShortcut(shortcutLock)
|
|
||||||
case "N":
|
|
||||||
_ = u.performShortcut(shortcutNewEntry)
|
|
||||||
case "U":
|
|
||||||
_ = u.performShortcut(shortcutCopyUser)
|
|
||||||
case "P":
|
|
||||||
_ = u.performShortcut(shortcutCopyPassword)
|
|
||||||
case "O":
|
|
||||||
_ = u.performShortcut(shortcutCopyURL)
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) activateBreadcrumb(index int) {
|
|
||||||
var path []string
|
|
||||||
if index <= 0 {
|
|
||||||
path = nil
|
|
||||||
} else {
|
|
||||||
crumbs := u.breadcrumbLabels()
|
|
||||||
path = append([]string{}, crumbs[1:index+1]...)
|
|
||||||
}
|
|
||||||
u.state.NavigateToPath(path)
|
|
||||||
u.filter()
|
|
||||||
if index >= len(u.breadcrumbLabels()) {
|
|
||||||
index = len(u.breadcrumbLabels()) - 1
|
|
||||||
}
|
|
||||||
if index < 0 {
|
|
||||||
index = 0
|
|
||||||
}
|
|
||||||
u.keyboardFocus = breadcrumbFocusID(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) breadcrumbLabels() []string {
|
|
||||||
if u.state.Section == appstate.SectionRecycleBin {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
labels := append([]string{"Vault"}, u.state.CurrentPath...)
|
|
||||||
if u.state.Section == appstate.SectionTemplates {
|
|
||||||
labels = append([]string{"Templates"}, u.state.CurrentPath...)
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) focusListIndex(index int) {
|
|
||||||
if len(u.visible) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if index < 0 {
|
|
||||||
index = 0
|
|
||||||
}
|
|
||||||
if index >= len(u.visible) {
|
|
||||||
index = len(u.visible) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
u.keyboardFocus = listFocusID(index)
|
|
||||||
u.state.SelectedEntryID = u.visible[index].ID
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) focusedListIndexOrZero() int {
|
|
||||||
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
|
|
||||||
index := focusIndex(u.keyboardFocus)
|
|
||||||
if index >= 0 && index < len(u.visible) {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, item := range u.visible {
|
|
||||||
if item.ID == u.state.SelectedEntryID {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) focusedDetailFieldOrDefault() detailField {
|
|
||||||
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
|
|
||||||
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
|
|
||||||
for _, field := range detailFocusOrder() {
|
|
||||||
if string(field) == name {
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return detailFieldTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) focusedDetailIndex() int {
|
|
||||||
current := u.focusedDetailFieldOrDefault()
|
|
||||||
for i, field := range detailFocusOrder() {
|
|
||||||
if field == current {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func detailFocusOrder() []detailField {
|
|
||||||
return editormodel.FocusOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalFocusID(id focusID) focusID {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
|
||||||
return breadcrumbFocusID(0)
|
|
||||||
case strings.HasPrefix(string(id), "list:"):
|
|
||||||
return listFocusID(0)
|
|
||||||
case strings.HasPrefix(string(id), "detail:"):
|
|
||||||
return detailFocusID(detailFieldTitle)
|
|
||||||
default:
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func focusIndex(id focusID) int {
|
|
||||||
_, value, ok := strings.Cut(string(id), ":")
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
index, err := strconv.Atoi(value)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package lifecycle
|
|
||||||
|
|
||||||
type OpenIntent string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OpenIntentNone OpenIntent = ""
|
|
||||||
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
|
||||||
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
|
||||||
)
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/x/explorer"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) createVaultAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
|
||||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
|
||||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.CreateVault(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.lifecycleMode == "local" {
|
|
||||||
u.selectedVaultRemoteProfileID = ""
|
|
||||||
u.selectedVaultRemoteCredentialEntryID = ""
|
|
||||||
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
|
||||||
u.remoteBaseURL.SetText("")
|
|
||||||
u.remotePath.SetText("")
|
|
||||||
u.remoteUsername.SetText("")
|
|
||||||
u.remotePassword.SetText("")
|
|
||||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.vaultPath.SetText(u.saveAsTargetPath())
|
|
||||||
u.noteRecentVault(u.saveAsTargetPath())
|
|
||||||
}
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openVaultAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.OpenVault(path, key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.restoreRecentVaultGroup(path)
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
|
||||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
|
||||||
}
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
u.applyPendingLifecycleOpenIntent()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startOpenVaultAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("open vault", u.openVaultAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.lastLifecycleAction = "open vault"
|
|
||||||
u.runBackgroundAction("open vault", func() (func() error, error) {
|
|
||||||
prepared, err := session.PrepareLocalOpen(path, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedLocalOpen(prepared)
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.restoreRecentVaultGroup(path)
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
|
||||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
|
||||||
}
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
u.applyPendingLifecycleOpenIntent()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
|
||||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return "Open Vault And Set Up Remote Sync"
|
|
||||||
}
|
|
||||||
if hasBoundRecentRemote(u.recentRemotes, path) {
|
|
||||||
return "Open Vault And Open Remote Sync Settings"
|
|
||||||
}
|
|
||||||
return "Open Vault And Set Up Remote Sync"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
switch {
|
|
||||||
case path == "":
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
|
||||||
case hasBoundRecentRemote(u.recentRemotes, path):
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
|
||||||
default:
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
|
||||||
}
|
|
||||||
u.startOpenVaultAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) applyPendingLifecycleOpenIntent() {
|
|
||||||
intent := u.pendingLifecycleOpenIntent
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
|
||||||
switch intent {
|
|
||||||
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
|
||||||
u.openRemoteSyncSetupDialog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAction() error {
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAsAction() error {
|
|
||||||
path := u.saveAsTargetPath()
|
|
||||||
if err := u.state.SaveAs(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.vaultPath.SetText(path)
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openRemoteAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
|
||||||
return err
|
|
||||||
} else if ok {
|
|
||||||
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentRemote(
|
|
||||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
strings.TrimSpace(u.remotePath.Text()),
|
|
||||||
)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startOpenRemoteAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("open remote vault", u.openRemoteAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}
|
|
||||||
remotePath := strings.TrimSpace(u.remotePath.Text())
|
|
||||||
u.lastLifecycleAction = "open remote vault"
|
|
||||||
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
|
||||||
binding, bindingOK := u.selectedVaultRemoteBinding()
|
|
||||||
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
|
||||||
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolved, err := binding.Resolve(preparedLocal.Model)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
|
||||||
BaseURL: resolved.Profile.BaseURL,
|
|
||||||
Username: resolved.Credentials.Username,
|
|
||||||
Password: resolved.Credentials.Password,
|
|
||||||
}, resolved.Profile.Path, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedLocalOpen(preparedLocal)
|
|
||||||
u.vaultPath.SetText(binding.LocalVaultPath)
|
|
||||||
u.noteRecentVault(binding.LocalVaultPath)
|
|
||||||
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
|
||||||
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if u.hasOpenVault() {
|
|
||||||
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if ok {
|
|
||||||
client = webdav.Client{
|
|
||||||
BaseURL: resolved.Profile.BaseURL,
|
|
||||||
Username: resolved.Credentials.Username,
|
|
||||||
Password: resolved.Credentials.Password,
|
|
||||||
}
|
|
||||||
remotePath = resolved.Profile.Path
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedRemoteOpen(prepared)
|
|
||||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentRemote(
|
|
||||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
remotePath,
|
|
||||||
)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) lockAction() error {
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err := u.state.Lock(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) unlockAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.Unlock(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startUnlockAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("unlock vault", u.unlockAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
|
||||||
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
|
||||||
prepared, err := session.PrepareUnlock(encoded, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedUnlock(prepared)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.adoptStateCurrentPath()
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) changeMasterKeyAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.state.ChangeMasterKey(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadSecuritySettingsFromSession() {
|
|
||||||
settings, err := u.state.SecuritySettings()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.securityCipher.SetText(settings.Cipher)
|
|
||||||
u.securityKDF.SetText(settings.KDF)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) clearMasterPassword() {
|
|
||||||
u.masterPassword.SetText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) synchronizeAction() error {
|
|
||||||
if err := u.state.Synchronize(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openAdvancedSyncDialog() {
|
|
||||||
u.syncDialogOpen = true
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.syncDialogList.Position = layout.Position{}
|
|
||||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
|
||||||
u.syncSourceMode = u.syncDefaultSourceMode
|
|
||||||
u.syncDirection = u.syncDefaultDirection
|
|
||||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
|
||||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
|
||||||
}
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openRemoteSyncSetupDialog() {
|
|
||||||
u.syncDialogOpen = true
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.syncDialogList.Position = layout.Position{}
|
|
||||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
|
||||||
u.syncSourceMode = syncSourceRemote
|
|
||||||
u.syncDirection = syncDirectionPush
|
|
||||||
u.syncSetupAutomatic.Value = true
|
|
||||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
|
||||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
|
||||||
}
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
|
||||||
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
|
||||||
u.syncSetupAutomatic.Value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) clearSyncLocalImport() {
|
|
||||||
u.syncLocalImportName = ""
|
|
||||||
u.syncLocalImportContent = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
|
||||||
name := strings.TrimSpace(u.syncLocalImportName)
|
|
||||||
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
|
||||||
switch mode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
return syncSourceRemote
|
|
||||||
default:
|
|
||||||
return syncSourceLocal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
|
||||||
switch direction {
|
|
||||||
case syncDirectionPush:
|
|
||||||
return syncDirectionPush
|
|
||||||
default:
|
|
||||||
return syncDirectionPull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncAction() error {
|
|
||||||
switch u.syncDirection {
|
|
||||||
case syncDirectionPush:
|
|
||||||
return u.advancedSyncToAction()
|
|
||||||
default:
|
|
||||||
return u.advancedSyncFromAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncFromAction() error {
|
|
||||||
switch u.syncSourceMode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
|
||||||
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.syncDialogOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startChooseSyncLocalSourceAction() {
|
|
||||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
|
||||||
u.runAction("choose sync path", func() error {
|
|
||||||
u.clearSyncLocalImport()
|
|
||||||
return u.chooseExistingFileAction(&u.syncLocalPath)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
label := "Selected Android vault"
|
|
||||||
return func() error {
|
|
||||||
u.syncLocalImportName = label
|
|
||||||
u.syncLocalImportContent = append([]byte(nil), content...)
|
|
||||||
u.syncLocalPath.SetText(label)
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
|
||||||
if named, ok := file.(interface{ Name() string }); ok {
|
|
||||||
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fallback = filepath.Base(strings.TrimSpace(fallback))
|
|
||||||
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
|
||||||
return "selected-vault.kdbx"
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startChooseVaultPathAction() {
|
|
||||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
|
||||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
name := pickedDocumentName(file, "selected-vault.kdbx")
|
|
||||||
return func() error {
|
|
||||||
return u.importSharedVaultBytesAction(name, content)
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startImportSharedVaultAction() {
|
|
||||||
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncToAction() error {
|
|
||||||
switch u.syncSourceMode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
|
||||||
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
|
||||||
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.showStatusMessage("Remote sync is set up for this vault.")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.syncDialogOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
|
||||||
baseURL = strings.TrimSpace(baseURL)
|
|
||||||
remotePath = strings.TrimSpace(remotePath)
|
|
||||||
if baseURL == "" || remotePath == "" {
|
|
||||||
return fmt.Errorf("remote setup requires base URL and path")
|
|
||||||
}
|
|
||||||
input := appstate.RemoteBindingInput{
|
|
||||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
|
||||||
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
|
||||||
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
|
||||||
BaseURL: baseURL,
|
|
||||||
RemotePath: remotePath,
|
|
||||||
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
|
||||||
CredentialTitle: "WebDAV Sign-In" + func() string {
|
|
||||||
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
|
||||||
return " · " + user
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}(),
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
CredentialPath: append([]string(nil), u.currentPath...),
|
|
||||||
SyncMode: u.syncSetupMode(),
|
|
||||||
}
|
|
||||||
binding, err := u.state.ConfigureRemoteBinding(input)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
|
||||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
|
||||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
|
||||||
u.remoteBaseURL.SetText(baseURL)
|
|
||||||
u.remotePath.SetText(remotePath)
|
|
||||||
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
|
||||||
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
|
||||||
u.noteRecentRemote(baseURL, remotePath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAsTargetPath() string {
|
|
||||||
path := strings.TrimSpace(u.saveAsPath.Text())
|
|
||||||
if path != "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return u.defaultSaveAsPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) importedVaultDestination(name string) string {
|
|
||||||
baseTarget := u.saveAsTargetPath()
|
|
||||||
baseDir := filepath.Dir(baseTarget)
|
|
||||||
baseName := filepath.Base(strings.TrimSpace(name))
|
|
||||||
switch {
|
|
||||||
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
|
||||||
return baseTarget
|
|
||||||
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
|
||||||
return filepath.Join(baseDir, baseName)
|
|
||||||
default:
|
|
||||||
return baseTarget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) consumePendingSharedVaultImport() {
|
|
||||||
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
|
||||||
if path == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := "shared-vault.kdbx"
|
|
||||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
|
||||||
if rawName, err := os.ReadFile(namePath); err == nil {
|
|
||||||
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
|
||||||
name = trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
|
||||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = os.Remove(path)
|
|
||||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
|
||||||
_ = os.Remove(namePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
|
||||||
target := u.importedVaultDestination(name)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.lifecycleMode = "local"
|
|
||||||
u.vaultPath.SetText(target)
|
|
||||||
u.noteRecentVault(target)
|
|
||||||
u.state.ErrorMessage = ""
|
|
||||||
u.state.StatusMessage = ""
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) currentShareableVaultPath() string {
|
|
||||||
return strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) shareCurrentVaultAction() error {
|
|
||||||
if u.vaultSharer == nil {
|
|
||||||
return fmt.Errorf("vault sharing is not available on this platform")
|
|
||||||
}
|
|
||||||
path := u.currentShareableVaultPath()
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
type TopSection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TopSearch TopSection = "search"
|
|
||||||
TopNavigation TopSection = "navigation"
|
|
||||||
TopPath TopSection = "path"
|
|
||||||
TopGroup TopSection = "group"
|
|
||||||
TopGroupTools TopSection = "group_tools"
|
|
||||||
TopPrimary TopSection = "primary"
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package list
|
|
||||||
|
|
||||||
type EntriesSectionState struct {
|
|
||||||
Path []string
|
|
||||||
SearchQuery string
|
|
||||||
SelectedEntryID string
|
|
||||||
Editing bool
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
//go:build android
|
|
||||||
|
|
||||||
package platform
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo CFLAGS: -Werror
|
|
||||||
#cgo LDFLAGS: -landroid
|
|
||||||
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
import "unsafe"
|
|
||||||
|
|
||||||
func LogInfo(tag, msg string) {
|
|
||||||
ctag := C.CString(tag)
|
|
||||||
defer C.free(unsafe.Pointer(ctag))
|
|
||||||
cmsg := C.CString(msg)
|
|
||||||
defer C.free(unsafe.Pointer(cmsg))
|
|
||||||
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
//go:build android
|
|
||||||
|
|
||||||
package platform
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo CFLAGS: -Werror
|
|
||||||
#cgo LDFLAGS: -landroid
|
|
||||||
|
|
||||||
#include <jni.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
|
|
||||||
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
|
||||||
return (*env)->GetObjectClass(env, obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
|
||||||
return (*env)->GetMethodID(env, clazz, name, sig);
|
|
||||||
}
|
|
||||||
|
|
||||||
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
|
||||||
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
|
||||||
}
|
|
||||||
|
|
||||||
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
|
||||||
return (*env)->CallObjectMethodA(env, obj, method, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
|
||||||
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
static jvalue jni_ValueObject(jobject obj) {
|
|
||||||
jvalue value;
|
|
||||||
value.l = obj;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
|
||||||
return (*env)->ExceptionOccurred(env);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void jni_ExceptionClear(JNIEnv *env) {
|
|
||||||
(*env)->ExceptionClear(env);
|
|
||||||
}
|
|
||||||
|
|
||||||
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
|
||||||
return (*env)->NewString(env, unicodeChars, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int jni_IsNull(jobject obj) {
|
|
||||||
return obj == NULL;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf16"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"gioui.org/app"
|
|
||||||
_ "unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
type androidVaultSharer struct{}
|
|
||||||
|
|
||||||
//go:linkname gioJavaVM gioui.org/app.javaVM
|
|
||||||
func gioJavaVM() *C.JavaVM
|
|
||||||
|
|
||||||
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
|
||||||
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
|
||||||
|
|
||||||
func NewVaultSharer(goos string) VaultSharer {
|
|
||||||
return androidVaultSharer{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (androidVaultSharer) ShareVault(path, title string) error {
|
|
||||||
if strings.TrimSpace(path) == "" {
|
|
||||||
return fmt.Errorf("vault path is required")
|
|
||||||
}
|
|
||||||
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
|
||||||
if C.jni_IsNull(ctx) != 0 {
|
|
||||||
return fmt.Errorf("android app context is not available")
|
|
||||||
}
|
|
||||||
var callErr error
|
|
||||||
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
|
||||||
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
|
||||||
if err != nil {
|
|
||||||
callErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
methodName := cString("shareVault")
|
|
||||||
defer C.free(unsafe.Pointer(methodName))
|
|
||||||
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
|
||||||
defer C.free(unsafe.Pointer(methodSig))
|
|
||||||
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
|
||||||
if method == nil {
|
|
||||||
callErr = androidJNIError(env, "resolve shareVault method")
|
|
||||||
if callErr == nil {
|
|
||||||
callErr = fmt.Errorf("resolve shareVault method")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jPath := androidJavaString(env, path)
|
|
||||||
jTitle := androidJavaString(env, title)
|
|
||||||
args := [3]C.jvalue{}
|
|
||||||
args[0] = C.jni_ValueObject(ctx)
|
|
||||||
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
|
||||||
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
|
||||||
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
|
||||||
callErr = androidJNIError(env, "share vault")
|
|
||||||
})
|
|
||||||
return callErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
|
||||||
var zeroClass C.jclass
|
|
||||||
contextClass := C.jni_GetObjectClass(env, ctx)
|
|
||||||
getClassLoaderName := cString("getClassLoader")
|
|
||||||
defer C.free(unsafe.Pointer(getClassLoaderName))
|
|
||||||
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
|
||||||
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
|
||||||
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
|
||||||
if getClassLoader == nil {
|
|
||||||
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
|
||||||
return zeroClass, err
|
|
||||||
}
|
|
||||||
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
|
||||||
}
|
|
||||||
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
|
||||||
if err := androidJNIError(env, "load class loader"); err != nil {
|
|
||||||
return zeroClass, err
|
|
||||||
}
|
|
||||||
if C.jni_IsNull(classLoader) != 0 {
|
|
||||||
return zeroClass, fmt.Errorf("android class loader is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
|
||||||
loadClassName := cString("loadClass")
|
|
||||||
defer C.free(unsafe.Pointer(loadClassName))
|
|
||||||
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
|
||||||
defer C.free(unsafe.Pointer(loadClassSig))
|
|
||||||
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
|
||||||
if loadClass == nil {
|
|
||||||
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
|
||||||
return zeroClass, err
|
|
||||||
}
|
|
||||||
return zeroClass, fmt.Errorf("resolve loadClass")
|
|
||||||
}
|
|
||||||
|
|
||||||
jClassName := androidJavaString(env, name)
|
|
||||||
args := [1]C.jvalue{}
|
|
||||||
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
|
||||||
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
|
||||||
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
|
||||||
return zeroClass, err
|
|
||||||
}
|
|
||||||
if C.jni_IsNull(loaded) != 0 {
|
|
||||||
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
|
||||||
}
|
|
||||||
return C.jclass(loaded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func androidJNIError(env *C.JNIEnv, action string) error {
|
|
||||||
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
|
||||||
C.jni_ExceptionClear(env)
|
|
||||||
return fmt.Errorf("%s: Java exception", action)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
|
||||||
chars := utf16.Encode([]rune(s))
|
|
||||||
if len(chars) == 0 {
|
|
||||||
return C.jni_NewString(env, nil, 0)
|
|
||||||
}
|
|
||||||
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func cString(value string) *C.char {
|
|
||||||
return C.CString(value)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//go:build !android
|
|
||||||
|
|
||||||
package platform
|
|
||||||
|
|
||||||
import "log"
|
|
||||||
|
|
||||||
func NewVaultSharer(goos string) VaultSharer {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogInfo(tag, msg string) {
|
|
||||||
log.Printf("%s: %s", tag, msg)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
gioclipboard "gioui.org/io/clipboard"
|
|
||||||
"gioui.org/layout"
|
|
||||||
|
|
||||||
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
|
|
||||||
)
|
|
||||||
|
|
||||||
type VaultSharer interface {
|
|
||||||
ShareVault(path, title string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type clipboardCommandWriter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
pending []string
|
|
||||||
invalidate func()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
|
||||||
if strings.EqualFold(goos, "android") {
|
|
||||||
return &clipboardCommandWriter{invalidate: invalidate}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
|
||||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commandWriter.Process(gtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *clipboardCommandWriter) WriteText(text string) error {
|
|
||||||
w.mu.Lock()
|
|
||||||
w.pending = append(w.pending, text)
|
|
||||||
w.mu.Unlock()
|
|
||||||
if w.invalidate != nil {
|
|
||||||
w.invalidate()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
|
|
||||||
for _, text := range w.drain() {
|
|
||||||
gtx.Execute(gioclipboard.WriteCmd{
|
|
||||||
Type: "application/text",
|
|
||||||
Data: io.NopCloser(strings.NewReader(text)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *clipboardCommandWriter) drain() []string {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
if len(w.pending) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pending := append([]string(nil), w.pending...)
|
|
||||||
w.pending = nil
|
|
||||||
return pending
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
writer := NewClipboardWriter("android", nil)
|
|
||||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
|
||||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
|
||||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
writer := &clipboardCommandWriter{}
|
|
||||||
if err := writer.WriteText("username"); err != nil {
|
|
||||||
t.Fatalf("WriteText(username) error = %v", err)
|
|
||||||
}
|
|
||||||
if err := writer.WriteText("password"); err != nil {
|
|
||||||
t.Fatalf("WriteText(password) error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
|
|
||||||
t.Fatalf("drain() = %v, want [username password]", got)
|
|
||||||
}
|
|
||||||
if got := writer.drain(); got != nil {
|
|
||||||
t.Fatalf("drain() after flush = %v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/app"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/x/explorer"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/api"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Main() {
|
|
||||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
|
||||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
|
||||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
|
||||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
|
||||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
|
||||||
|
|
||||||
width := unit.Dp(1180)
|
|
||||||
height := unit.Dp(760)
|
|
||||||
if strings.EqualFold(resolvedMode, "phone") {
|
|
||||||
width = unit.Dp(412)
|
|
||||||
height = unit.Dp(915)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
w := new(app.Window)
|
|
||||||
options := []app.Option{app.Title(productName)}
|
|
||||||
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
|
||||||
options = append(options, app.Size(width, height))
|
|
||||||
}
|
|
||||||
w.Option(options...)
|
|
||||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
app.Main()
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultGRPCAddr(goos string) string {
|
|
||||||
return grpcaddr.Default(goos)
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|
||||||
ensureBrowserNativeHosts()
|
|
||||||
var ops op.Ops
|
|
||||||
manager := &session.Manager{}
|
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
|
||||||
ui.fileExplorer = explorer.NewExplorer(w)
|
|
||||||
ui.invalidate = w.Invalidate
|
|
||||||
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
|
||||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
|
||||||
if err != nil {
|
|
||||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
|
||||||
} else if host != nil {
|
|
||||||
ui.apiHost = host
|
|
||||||
ui.auditLog = host.Server().AuditLog()
|
|
||||||
ui.state.AuditLog = ui.auditLog
|
|
||||||
ui.grpcAddress = host.Address()
|
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
|
||||||
host.Server().SetChangeNotifier(func() {
|
|
||||||
ui.state.Dirty = true
|
|
||||||
ui.invalidate()
|
|
||||||
})
|
|
||||||
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
|
||||||
defer func() { _ = host.Stop() }()
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
e := w.Event()
|
|
||||||
ui.fileExplorer.ListenEvents(e)
|
|
||||||
switch e := e.(type) {
|
|
||||||
case app.DestroyEvent:
|
|
||||||
return e.Err
|
|
||||||
case app.FrameEvent:
|
|
||||||
gtx := app.NewContext(&ops, e)
|
|
||||||
ui.processBackgroundActions()
|
|
||||||
ui.layout(gtx)
|
|
||||||
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
|
||||||
e.Frame(gtx.Ops)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureBrowserNativeHosts() {
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appBinaryPath, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
|
|
||||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type uiApprovalManager struct {
|
|
||||||
server *api.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
|
||||||
if m == nil || m.server == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m.server.ApprovalBroker().Pending()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
|
||||||
if m == nil || m.server == nil {
|
|
||||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
|
||||||
}
|
|
||||||
return m.server.ResolveApproval(id, outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
type uiSession struct {
|
|
||||||
model vault.Model
|
|
||||||
locked bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) HasVault() bool {
|
|
||||||
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) IsLocked() bool {
|
|
||||||
return s.locked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) IsRemote() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Current() (vault.Model, error) {
|
|
||||||
if s.locked {
|
|
||||||
return vault.Model{}, session.ErrLocked
|
|
||||||
}
|
|
||||||
return s.model, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Replace(model vault.Model) {
|
|
||||||
s.model = model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Lock() error {
|
|
||||||
s.locked = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Unlock(vault.MasterKey) error {
|
|
||||||
if !s.locked {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.locked = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickExistingFile() (string, error) {
|
|
||||||
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runFilePicker(name string, args ...string) (string, error) {
|
|
||||||
if _, err := exec.LookPath(name); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return parsePickedFilePath(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePickedFilePath(output []byte) (string, error) {
|
|
||||||
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
|
||||||
for i := len(lines) - 1; i >= 0; i-- {
|
|
||||||
line := strings.TrimSpace(lines[i])
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
|
||||||
return line, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("file picker did not return a path")
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op/clip"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
|
||||||
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
displayDensityDense = settingsmodel.DisplayDensityDense
|
|
||||||
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
|
||||||
|
|
||||||
contrastStandard = settingsmodel.ContrastStandard
|
|
||||||
contrastHigh = settingsmodel.ContrastHigh
|
|
||||||
|
|
||||||
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
|
||||||
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
|
||||||
)
|
|
||||||
|
|
||||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
|
||||||
|
|
||||||
type settingsFile struct {
|
|
||||||
Sync syncSettings `json:"sync,omitempty"`
|
|
||||||
Debug debugSettings `json:"debug,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type syncSettings struct {
|
|
||||||
SourceDefault string `json:"sourceDefault,omitempty"`
|
|
||||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
|
||||||
AutoSaveRemote bool `json:"autoSaveRemote,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type debugSettings struct {
|
|
||||||
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type syncSettingsDraft struct {
|
|
||||||
SourceDefault syncSourceMode
|
|
||||||
DirectionDefault syncDirection
|
|
||||||
AutoSaveRemote bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type settingsDraft struct {
|
|
||||||
Accessibility accessibilityPreferences
|
|
||||||
Sync syncSettingsDraft
|
|
||||||
Debug debugSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
type legacySyncPreferences struct {
|
|
||||||
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
|
|
||||||
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type choiceSpec struct {
|
|
||||||
Click *widget.Clickable
|
|
||||||
Label string
|
|
||||||
Active bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type focusAppearance struct {
|
|
||||||
BorderColor color.NRGBA
|
|
||||||
OutlineColor color.NRGBA
|
|
||||||
OutlineWidth int
|
|
||||||
MinHeight int
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
|
||||||
return settingsmodel.DefaultAccessibilityPreferences()
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDensityForDenseLayout(dense bool) string {
|
|
||||||
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
|
||||||
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
|
||||||
normalized := normalizeAccessibilityPreferences(prefs)
|
|
||||||
u.denseLayout = normalized.DisplayDensity == displayDensityDense
|
|
||||||
u.accessibilityPrefs = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
|
||||||
prefs = normalizeAccessibilityPreferences(prefs)
|
|
||||||
appearance := focusAppearance{
|
|
||||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
|
||||||
OutlineColor: color.NRGBA{A: 0},
|
|
||||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
|
||||||
MinHeight: metric.Dp(unit.Dp(44)),
|
|
||||||
}
|
|
||||||
if prefs.DisplayDensity == displayDensityComfortable {
|
|
||||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
|
||||||
}
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
|
||||||
}
|
|
||||||
if focused {
|
|
||||||
appearance.BorderColor = accentColor
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
|
||||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
|
||||||
}
|
|
||||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
|
||||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return appearance
|
|
||||||
}
|
|
||||||
|
|
||||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
|
||||||
prefs = normalizeAccessibilityPreferences(prefs)
|
|
||||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
|
||||||
text = accentColor
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
|
||||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
|
||||||
}
|
|
||||||
if focused {
|
|
||||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
|
||||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
|
||||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return background, text
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) accessibilityLabel(id focusID) string {
|
|
||||||
switch {
|
|
||||||
case id == focusSearch:
|
|
||||||
return "Search vault"
|
|
||||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
|
||||||
index := focusIndex(id)
|
|
||||||
crumbs := u.breadcrumbLabels()
|
|
||||||
if index >= 0 && index < len(crumbs) {
|
|
||||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(string(id), "list:"):
|
|
||||||
index := focusIndex(id)
|
|
||||||
if index >= 0 && index < len(u.visible) {
|
|
||||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(string(id), "detail:"):
|
|
||||||
name := strings.TrimPrefix(string(id), "detail:")
|
|
||||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
|
||||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
|
||||||
return layout.Dimensions{Size: size}
|
|
||||||
}
|
|
||||||
|
|
||||||
width := appearance.OutlineWidth
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
|
||||||
return layout.Dimensions{Size: size}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) isFocused(id focusID) bool {
|
|
||||||
return u.keyboardFocus == id
|
|
||||||
}
|
|
||||||
|
|
||||||
func detailFieldLabel(field detailField) string {
|
|
||||||
return editormodel.Label(field)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadSettingsDraft() {
|
|
||||||
u.settingsDraft = settingsDraft{
|
|
||||||
Accessibility: accessibilityPreferences{
|
|
||||||
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
|
|
||||||
Contrast: u.accessibilityPrefs.Contrast,
|
|
||||||
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
|
||||||
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
|
||||||
},
|
|
||||||
Sync: syncSettingsDraft{
|
|
||||||
SourceDefault: u.syncDefaultSourceMode,
|
|
||||||
DirectionDefault: u.syncDefaultDirection,
|
|
||||||
AutoSaveRemote: u.autoSaveRemote,
|
|
||||||
},
|
|
||||||
Debug: debugSettings{
|
|
||||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
|
||||||
u.settingsAutoSaveRemote.Value = u.settingsDraft.Sync.AutoSaveRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveSecuritySettingsAction() error {
|
|
||||||
if err := u.applySecuritySettingsLive(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.securityDialogOpen = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) applySecuritySettingsLive() error {
|
|
||||||
settings := vault.SecuritySettings{
|
|
||||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
|
||||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
|
||||||
}
|
|
||||||
if err := u.state.ConfigureSecurity(settings); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
|
||||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
|
||||||
}
|
|
||||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
|
||||||
u.settingsDraft.Sync.AutoSaveRemote = u.settingsAutoSaveRemote.Value
|
|
||||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
|
||||||
u.autoSaveRemote = u.settingsDraft.Sync.AutoSaveRemote
|
|
||||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
|
||||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
|
||||||
if !u.debugLogHeaderBounds {
|
|
||||||
u.lastHeaderBoundsLog = ""
|
|
||||||
}
|
|
||||||
u.applySettingsFormToPreferences()
|
|
||||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
|
||||||
u.saveSettings()
|
|
||||||
u.saveUIPreferences()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadSettings() {
|
|
||||||
u.syncDefaultSourceMode = syncSourceLocal
|
|
||||||
u.syncDefaultDirection = syncDirectionPull
|
|
||||||
u.autoSaveRemote = false
|
|
||||||
|
|
||||||
if strings.TrimSpace(u.settingsPath) != "" {
|
|
||||||
content, err := os.ReadFile(u.settingsPath)
|
|
||||||
if err == nil {
|
|
||||||
var settings settingsFile
|
|
||||||
if json.Unmarshal(content, &settings) == nil {
|
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
|
||||||
u.autoSaveRemote = settings.Sync.AutoSaveRemote
|
|
||||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
|
||||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
u.loadLegacySyncDefaultsFromUIPreferences()
|
|
||||||
u.state.AutoSaveRemote = u.autoSaveRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
|
||||||
if strings.TrimSpace(u.uiPreferencesPath) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(u.uiPreferencesPath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var prefs legacySyncPreferences
|
|
||||||
if err := json.Unmarshal(content, &prefs); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
|
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveSettings() {
|
|
||||||
if strings.TrimSpace(u.settingsPath) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, err := json.MarshalIndent(settingsFile{
|
|
||||||
Sync: syncSettings{
|
|
||||||
SourceDefault: string(u.syncDefaultSourceMode),
|
|
||||||
DirectionDefault: string(u.syncDefaultDirection),
|
|
||||||
AutoSaveRemote: u.autoSaveRemote,
|
|
||||||
},
|
|
||||||
Debug: debugSettings{
|
|
||||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
|
||||||
},
|
|
||||||
}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
|
||||||
if !u.debugLogHeaderBounds {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
line := bounds.logLine(u.mode)
|
|
||||||
if line == u.lastHeaderBoundsLog {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform.LogInfo("KeePassGO", line)
|
|
||||||
u.lastHeaderBoundsLog = line
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
|
|
||||||
if !u.debugLogHeaderBounds {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
|
|
||||||
if !u.debugLogHeaderBounds {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform.LogInfo("KeePassGO", fmt.Sprintf(
|
|
||||||
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
|
|
||||||
menu,
|
|
||||||
placement.Anchor.TriggerRightX,
|
|
||||||
placement.Anchor.TriggerBottomY,
|
|
||||||
placement.Origin.X,
|
|
||||||
placement.Origin.Y,
|
|
||||||
placement.Size.X,
|
|
||||||
placement.Size.Y,
|
|
||||||
surface.ContainerWidth,
|
|
||||||
surface.LeftInset,
|
|
||||||
surface.TopInset,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) showStatusMessage(message string) {
|
|
||||||
u.state.StatusMessage = message
|
|
||||||
if u.accessibilityPrefs.ReducedMotion {
|
|
||||||
u.statusExpiresAt = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
|
|
||||||
return sectionCard(gtx, u.theme, title, detail, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
|
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(th, unit.Sp(12), title)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(th, unit.Sp(13), body)
|
|
||||||
lbl.Color = th.Palette.Fg
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
|
|
||||||
children := make([]layout.FlexChild, 0, len(choices)*2)
|
|
||||||
for i, choice := range choices {
|
|
||||||
if i > 0 {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
current := choice
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type listRowColors struct {
|
|
||||||
Title color.NRGBA
|
|
||||||
Meta color.NRGBA
|
|
||||||
Secondary color.NRGBA
|
|
||||||
Divider color.NRGBA
|
|
||||||
Fill color.NRGBA
|
|
||||||
Edge color.NRGBA
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
|
|
||||||
colors := listRowColors{
|
|
||||||
Title: accentColor,
|
|
||||||
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
|
|
||||||
Secondary: mutedColor,
|
|
||||||
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
|
|
||||||
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
|
|
||||||
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
|
|
||||||
}
|
|
||||||
if selected {
|
|
||||||
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
|
||||||
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
|
|
||||||
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
|
|
||||||
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
|
|
||||||
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
|
|
||||||
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
|
|
||||||
}
|
|
||||||
if recycleBin {
|
|
||||||
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
|
|
||||||
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
|
|
||||||
}
|
|
||||||
if focused && !selected {
|
|
||||||
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
|
|
||||||
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
|
|
||||||
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
|
|
||||||
}
|
|
||||||
if u.accessibilityPrefs.Contrast == contrastHigh {
|
|
||||||
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
|
|
||||||
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
|
|
||||||
if focused || selected {
|
|
||||||
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
|
|
||||||
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
|
|
||||||
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
|
|
||||||
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
|
|
||||||
}
|
|
||||||
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
|
|
||||||
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
|
|
||||||
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
|
|
||||||
}
|
|
||||||
return colors
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package settings
|
|
||||||
|
|
||||||
type AccessibilityPreferences struct {
|
|
||||||
DisplayDensity string
|
|
||||||
Contrast string
|
|
||||||
ReducedMotion bool
|
|
||||||
KeyboardFocus string
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
DisplayDensityDense = "dense"
|
|
||||||
DisplayDensityComfortable = "comfortable"
|
|
||||||
ContrastStandard = "standard"
|
|
||||||
ContrastHigh = "high"
|
|
||||||
KeyboardFocusStandard = "standard"
|
|
||||||
KeyboardFocusProminent = "prominent"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
|
||||||
return AccessibilityPreferences{
|
|
||||||
DisplayDensity: DisplayDensityDense,
|
|
||||||
Contrast: ContrastStandard,
|
|
||||||
KeyboardFocus: KeyboardFocusStandard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DisplayDensityForDenseLayout(dense bool) string {
|
|
||||||
if dense {
|
|
||||||
return DisplayDensityDense
|
|
||||||
}
|
|
||||||
return DisplayDensityComfortable
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
|
||||||
normalized := DefaultAccessibilityPreferences()
|
|
||||||
switch prefs.DisplayDensity {
|
|
||||||
case DisplayDensityDense, DisplayDensityComfortable:
|
|
||||||
normalized.DisplayDensity = prefs.DisplayDensity
|
|
||||||
}
|
|
||||||
switch prefs.Contrast {
|
|
||||||
case ContrastStandard, ContrastHigh:
|
|
||||||
normalized.Contrast = prefs.Contrast
|
|
||||||
}
|
|
||||||
switch prefs.KeyboardFocus {
|
|
||||||
case KeyboardFocusStandard, KeyboardFocusProminent:
|
|
||||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
|
||||||
}
|
|
||||||
normalized.ReducedMotion = prefs.ReducedMotion
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
|
|
||||||
type SourceMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SourceLocal SourceMode = "local"
|
|
||||||
SourceRemote SourceMode = "remote"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Direction string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DirectionPull Direction = "pull"
|
|
||||||
DirectionPush Direction = "push"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DialogPurpose string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DialogPurposeAdvanced DialogPurpose = "advanced"
|
|
||||||
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MenuModel struct {
|
|
||||||
HasOpenVault bool
|
|
||||||
HasSelectedBinding bool
|
|
||||||
ShowSelectors bool
|
|
||||||
ShowShare bool
|
|
||||||
ShowSaveCurrentBinding bool
|
|
||||||
SavedBindingSummary MenuBindingSummary
|
|
||||||
RemoteBaseURL string
|
|
||||||
RemotePath string
|
|
||||||
RemoteUsername string
|
|
||||||
RemotePassword string
|
|
||||||
SelectedVaultSyncMode appstate.SyncMode
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuBindingSummary struct {
|
|
||||||
ProfileLabel string
|
|
||||||
CredentialLabel string
|
|
||||||
SyncLabel string
|
|
||||||
OK bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SavedBindingHeading() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use this vault's saved remote sync target"
|
|
||||||
}
|
|
||||||
return "Use a saved remote profile from this vault"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) OpenSelectedButtonLabel() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use Remote Sync"
|
|
||||||
}
|
|
||||||
return "Open Saved Remote"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
|
||||||
|
|
||||||
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
|
||||||
return m.HasOpenVault && !m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ActionLabels() []string {
|
|
||||||
labels := []string{"Open Advanced Sync"}
|
|
||||||
if m.ShowRemoteSyncSetupShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowDirectRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
|
||||||
return "Bind this local vault to the current remote target"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
|
||||||
|
|
||||||
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
|
||||||
if purpose == DialogPurposeRemoteSetup {
|
|
||||||
return "Push this local vault to a WebDAV target and save that target for future sync."
|
|
||||||
}
|
|
||||||
sourceLabel := "another local vault file"
|
|
||||||
if source == SourceRemote {
|
|
||||||
sourceLabel = "another WebDAV-backed vault"
|
|
||||||
}
|
|
||||||
action := "Pull changes from"
|
|
||||||
if direction == DirectionPush {
|
|
||||||
action = "Push the current vault into"
|
|
||||||
}
|
|
||||||
return action + " " + sourceLabel + "."
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op/clip"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Stack{}.Layout(gtx,
|
|
||||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
||||||
}),
|
|
||||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
width := gtx.Dp(unit.Dp(620))
|
|
||||||
if width > gtx.Constraints.Max.X {
|
|
||||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
|
||||||
}
|
|
||||||
if width < 1 {
|
|
||||||
width = gtx.Constraints.Max.X
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
return card(gtx, u.syncDialogContent)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
|
||||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
|
||||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
|
||||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
|
||||||
}
|
|
||||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncDirectionChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncDirectionChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncSourceChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncSourceChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.syncSourceMode == syncSourceRemote {
|
|
||||||
children := []layout.FlexChild{
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.syncPasswordField(gtx)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
|
||||||
check.Color = accentColor
|
|
||||||
return check.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if len(matchingCredentials) > 0 {
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for i, entry := range matchingCredentials {
|
|
||||||
i := i
|
|
||||||
entry := entry
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
label := entry.Title
|
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
|
||||||
label += " · " + strings.TrimSpace(entry.Username)
|
|
||||||
}
|
|
||||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
||||||
}
|
|
||||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
|
||||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
field := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
|
||||||
editor.Color = u.theme.Palette.Fg
|
|
||||||
editor.HintColor = mutedColor
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
|
||||||
}
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.outlinedFieldState(gtx, false, field)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package assets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"embed"
|
|
||||||
"image"
|
|
||||||
"image/png"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed *.png *.svg
|
|
||||||
var files embed.FS
|
|
||||||
|
|
||||||
func MustPNG(name string) image.Image {
|
|
||||||
data, err := files.ReadFile(name)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
img, err := png.Decode(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return img
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
|
|
||||||
<title>KeePassGO icon</title>
|
|
||||||
<desc>Vault-shaped mark with lock, layered record lines, and navigation accent.</desc>
|
|
||||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
|
||||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
|
||||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
|
||||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
|
|
||||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
|
||||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
|
||||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
|
||||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
|
||||||
<circle cx="128" cy="138" r="8" fill="#FFFFFF" opacity="0.2"/>
|
|
||||||
|
|
||||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
|
||||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,22 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="260" viewBox="0 0 920 260" fill="none">
|
|
||||||
<title>KeePassGO horizontal logo</title>
|
|
||||||
<desc>KeePassGO symbol with wordmark for light-theme desktop UI.</desc>
|
|
||||||
<defs>
|
|
||||||
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
|
||||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
|
||||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
|
||||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
|
||||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
|
||||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
|
||||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
|
||||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
|
||||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
|
||||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
|
||||||
</symbol>
|
|
||||||
</defs>
|
|
||||||
<rect width="920" height="260" fill="transparent"/>
|
|
||||||
<use href="#kpg-icon" x="24" y="20" width="176" height="176"/>
|
|
||||||
<text x="220" y="132" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="84" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,75 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" fill="none">
|
|
||||||
<title>KeePassGO splash screen</title>
|
|
||||||
<desc>Light-theme desktop splash screen with structured panels and the KeePassGO logo.</desc>
|
|
||||||
<defs>
|
|
||||||
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
|
||||||
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
|
||||||
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
|
||||||
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
|
||||||
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
|
||||||
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
|
||||||
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
|
||||||
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
|
||||||
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
|
||||||
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
|
||||||
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
|
||||||
</symbol>
|
|
||||||
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stop-color="#D9E0E6" stop-opacity="0.9"/>
|
|
||||||
<stop offset="1" stop-color="#D9E0E6" stop-opacity="0.2"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="1280" height="800" fill="#F7F7F5"/>
|
|
||||||
|
|
||||||
<g opacity="0.9">
|
|
||||||
<rect x="48" y="48" width="238" height="188" fill="#EEF2F4"/>
|
|
||||||
<rect x="286" y="48" width="152" height="104" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="438" y="48" width="214" height="104" fill="#E7EDF1"/>
|
|
||||||
<rect x="652" y="48" width="146" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="798" y="48" width="224" height="118" fill="#EEF2F4"/>
|
|
||||||
<rect x="1022" y="48" width="210" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
|
|
||||||
<rect x="48" y="236" width="128" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="176" y="236" width="262" height="164" fill="#E7EDF1"/>
|
|
||||||
<rect x="438" y="236" width="160" height="84" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="598" y="236" width="200" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="798" y="236" width="434" height="164" fill="#EEF2F4"/>
|
|
||||||
|
|
||||||
<rect x="48" y="400" width="224" height="128" fill="#EEF2F4"/>
|
|
||||||
<rect x="272" y="400" width="166" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="438" y="400" width="360" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="798" y="400" width="152" height="128" fill="#E7EDF1"/>
|
|
||||||
<rect x="950" y="400" width="282" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
|
|
||||||
<rect x="48" y="528" width="114" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="162" y="528" width="276" height="224" fill="#E7EDF1"/>
|
|
||||||
<rect x="438" y="528" width="212" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
<rect x="650" y="528" width="318" height="224" fill="#EEF2F4"/>
|
|
||||||
<rect x="968" y="528" width="264" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g opacity="0.45">
|
|
||||||
<path d="M48 152H1232" stroke="#C7D0D8"/>
|
|
||||||
<path d="M48 320H1232" stroke="#C7D0D8"/>
|
|
||||||
<path d="M48 528H1232" stroke="#C7D0D8"/>
|
|
||||||
<path d="M176 48V752" stroke="#C7D0D8"/>
|
|
||||||
<path d="M438 48V752" stroke="#C7D0D8"/>
|
|
||||||
<path d="M798 48V752" stroke="#C7D0D8"/>
|
|
||||||
<path d="M1022 48V752" stroke="#C7D0D8"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<rect x="290" y="190" width="700" height="420" rx="18" fill="#F7F7F5" opacity="0.92"/>
|
|
||||||
<rect x="290" y="190" width="700" height="420" rx="18" stroke="#C7D0D8"/>
|
|
||||||
<rect x="290" y="190" width="700" height="120" rx="18" fill="url(#fade)"/>
|
|
||||||
|
|
||||||
<use href="#kpg-icon" x="530" y="232" width="220" height="220"/>
|
|
||||||
<text x="640" y="530" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
|
||||||
<text x="640" y="582" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
|
|
||||||
|
|
||||||
<g opacity="0.9">
|
|
||||||
<rect x="516" y="634" width="248" height="10" rx="5" fill="#D9E0E6"/>
|
|
||||||
<rect x="516" y="634" width="132" height="10" rx="5" fill="#6D89A8"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user