Compare commits
168 Commits
198732239a
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f18e89bf | |||
| c361ec5ba3 | |||
| 0c6d707325 | |||
| 1c72a5009f | |||
| 9aeb98da58 | |||
| c19bdd48e2 | |||
| e5f42924f8 | |||
| 13eb9028c7 | |||
| 5fa79bdf08 | |||
| 1a11944423 | |||
| b0842566c0 | |||
| eb6624cba5 | |||
| 37f1a0ef8f | |||
| da4a8d4622 | |||
| 81df59ee15 | |||
| 69fac41003 | |||
| 1ee916b863 | |||
| 9c41b4814b | |||
| 2f4f016fe7 | |||
| 832aa86a34 | |||
| a4933f2127 | |||
| 6afdf49f5e | |||
| 9269c4b488 | |||
| 78313f130d | |||
| e75b3eb418 | |||
| ae7483e929 | |||
| db0501fca0 | |||
| 6629ba2483 | |||
| 3dc0f13af9 | |||
| b3db70db91 | |||
| e2addfc288 | |||
| e0fba15246 | |||
| a8bb5bf6b9 | |||
| b76cb7e1df | |||
| 3667fce5f4 | |||
| c2a4fa4087 | |||
| e4ebb2e723 | |||
| 7fc0661b32 | |||
| 5b6f7bb1de | |||
| b030b69c0d | |||
| 25c972263f | |||
| 818cb2d70e | |||
| c0afe96f56 | |||
| 6cfb5ea567 | |||
| ea1cf43cbf | |||
| 9a920f9a5a | |||
| b813c8f221 | |||
| 6f2b4d49b3 | |||
| 7ee969fb81 | |||
| 99a581674d | |||
| 004a0278a7 | |||
| 2aa859f7cb | |||
| 75fbd340aa | |||
| 1a8113d43b | |||
| dafc696053 | |||
| f205e753e8 | |||
| 3324eec9ec | |||
| 8c339eb309 | |||
| 9ec8ef12b0 | |||
| 468b2dc933 | |||
| a1cbec85da | |||
| 7de55d1041 | |||
| 1468bd5c5a | |||
| a2a7cb431d | |||
| baf3f27656 | |||
| 72e67039cf | |||
| b8b4ab9e1d | |||
| 84fea5e5d7 | |||
| 73ff0fb77d | |||
| bd99b928d9 | |||
| 06ea95f0b3 | |||
| b0721e5af1 | |||
| d9d1cf134d | |||
| cc7214b880 | |||
| 4cad54a696 | |||
| 8499b4304e | |||
| eb73cab155 | |||
| beefd51b15 | |||
| 0ab444b0b9 | |||
| 867aae5ffe | |||
| ff7f84c386 | |||
| 2000c27324 | |||
| 182b469b0b | |||
| 27fdd77aa1 | |||
| 88151dc780 | |||
| b2c26622e8 | |||
| 84f39b99de | |||
| fca121f170 | |||
| 794eed04d4 | |||
| e9eca73336 | |||
| 74dfe3f3d0 | |||
| f77a185e46 | |||
| 6a7594e128 | |||
| 666252f18c | |||
| f87b3f1989 | |||
| caf4ec6266 | |||
| 47d0ccc7ce | |||
| 96ec583c7e | |||
| 6e2760c514 | |||
| 59905ccd98 | |||
| 62bb20edb0 | |||
| 4e082c345a | |||
| 5bcd951e6a | |||
| c0747b62a0 | |||
| 3066442c1b | |||
| 4a3e52ec1c | |||
| daecb5ff32 | |||
| 99113aa26b | |||
| 4b78a649b1 | |||
| 2e9e2aae5f | |||
| db06fcd385 | |||
| d7026d5c0e | |||
| 20d5b3ba3d | |||
| 1c4fb59960 | |||
| fe3fa854bb | |||
| 37b83e654a | |||
| 761fae9b9b | |||
| fc9580403d | |||
| 92e8fce0e7 | |||
| cd21cc26c9 | |||
| fa75908552 | |||
| 7e2e396264 | |||
| 01e06deeef | |||
| 97f6a34472 | |||
| fc8630f894 | |||
| ef0fb7f5d9 | |||
| 3d0a6bc43a | |||
| e693aab13b | |||
| a2f1539027 | |||
| 508fe7abc1 | |||
| ed1e2c3e6b | |||
| 2b535a90e4 | |||
| 3f29fae12f | |||
| c050cb3e40 | |||
| cfec0c6703 | |||
| 00717f32ce | |||
| fc90e91174 | |||
| c248b89fcd | |||
| 73992a95cc | |||
| ba945b5527 | |||
| 43fc278fd1 | |||
| 484448b51c | |||
| a708e17b50 | |||
| 17ff374be7 | |||
| d05830335f | |||
| 4848b09be1 | |||
| 5a458c0c8c | |||
| beda174819 | |||
| 4e67328a09 | |||
| c5670a9a36 | |||
| a14101e415 | |||
| c5e2df4ca7 | |||
| 43d253aa21 | |||
| 6c5e9b42d3 | |||
| 5a6ba8ff57 | |||
| 6c1ccdad16 | |||
| 6748d31f87 | |||
| 40fd1bfde9 | |||
| 57f6ae658d | |||
| a857f972ea | |||
| f009573b4a | |||
| 4b4696ce30 | |||
| afa6c8821f | |||
| afe9680d7a | |||
| 422de535af | |||
| 44bba18149 | |||
| e15cfb1535 | |||
| a2a8fcbd14 |
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
|
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
|
||||||
|
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
|
||||||
|
|
||||||
|
Typical local build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Emulator Workflow
|
||||||
|
|
||||||
|
1. Reuse an existing emulator session if one is already running.
|
||||||
|
2. Prefer the `KeepassGoAPI35` AVD.
|
||||||
|
3. Install with `adb install -r build/keepassgo.apk`.
|
||||||
|
4. Launch with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Confirm focus before drawing conclusions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Capture evidence before editing code:
|
||||||
|
screenshot
|
||||||
|
focused window
|
||||||
|
relevant `logcat`
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
- APK builds successfully with `make apk`.
|
||||||
|
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
|
||||||
|
- Screenshot shows the expected screen, not just a black frame.
|
||||||
|
- `logcat` shows no app crash or Android runtime fatal error.
|
||||||
|
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
|
||||||
|
|
||||||
|
## Issue-Specific Checks
|
||||||
|
|
||||||
|
### Black Screen
|
||||||
|
|
||||||
|
- Confirm the app is focused.
|
||||||
|
- Capture a screenshot and `logcat` before changing anything.
|
||||||
|
- Compare the current Gio version against the known `v0.9.0` regression history.
|
||||||
|
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
|
||||||
|
|
||||||
|
### Clipboard
|
||||||
|
|
||||||
|
- Verify behavior in the emulator, not just unit tests.
|
||||||
|
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
|
||||||
|
|
||||||
|
### File Picker Or Local Sync
|
||||||
|
|
||||||
|
- Verify the picker flow on Android itself.
|
||||||
|
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
|
||||||
|
|
||||||
|
## Report Back
|
||||||
|
|
||||||
|
When closing out work, state:
|
||||||
|
|
||||||
|
- the exact build command used
|
||||||
|
- whether the emulator session was reused or newly started
|
||||||
|
- whether install succeeded
|
||||||
|
- whether the app actually rendered
|
||||||
|
- what was verified manually in the emulator
|
||||||
|
- what remains unverified
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 main.appVersion=${app_version}" -o "${out}" .
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build APK
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
signkey_path="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$signkey_path"' EXIT
|
||||||
|
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
||||||
|
export APP_VERSION="$(git describe --tags --always --dirty)"
|
||||||
|
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
|
||||||
|
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
||||||
|
|
||||||
|
- name: Upload CI artifacts
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
with:
|
||||||
|
name: keepassgo-${{ gitea.sha }}
|
||||||
|
path: |
|
||||||
|
dist/keepassgo-linux-amd64
|
||||||
|
dist/keepassgo-windows-amd64.exe
|
||||||
|
dist/keepassgo-windows-arm64.exe
|
||||||
|
dist/keepassgo.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Publish release artifacts
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 scripts/gitea_release.py \
|
||||||
|
--server "${GITEA_SERVER_URL}" \
|
||||||
|
--repo "${GITEA_REPOSITORY}" \
|
||||||
|
--token "${GITEA_TOKEN}" \
|
||||||
|
--tag "${GITEA_REF_NAME}" \
|
||||||
|
"${DIST_DIR}/keepassgo-linux-amd64" \
|
||||||
|
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
|
||||||
|
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
|
||||||
|
"${DIST_DIR}/keepassgo.apk"
|
||||||
@@ -14,6 +14,8 @@ 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`,
|
||||||
@@ -126,6 +128,20 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Keep `golangci-lint` passing.
|
- Keep `golangci-lint` passing.
|
||||||
- Keep `go test ./...` passing.
|
- Keep `go test ./...` passing.
|
||||||
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
||||||
|
- Keep the Android build requirements aligned with the known working setup:
|
||||||
|
`ANDROID_SDK_ROOT=/opt/android-sdk`,
|
||||||
|
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
|
||||||
|
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
||||||
|
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
|
||||||
|
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
|
||||||
|
- Remember the known Android runtime regression:
|
||||||
|
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
|
||||||
|
- When validating an APK in the emulator, prefer the known KeePassGO setup:
|
||||||
|
AVD `KeepassGoAPI35`,
|
||||||
|
package `org.julianfamily.keepassgo`,
|
||||||
|
activity `org.gioui.GioActivity`.
|
||||||
|
- Do not run emulator/manual APK tests against the 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.
|
- 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.
|
- 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.
|
||||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Environment:
|
|||||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
||||||
- `APP_ID` overrides the Android application id.
|
- `APP_ID` overrides the Android application id.
|
||||||
|
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||||
- `APK_OUT` overrides the output path.
|
- `APK_OUT` overrides the output path.
|
||||||
- `APK_VERSION` overrides the packaged app version.
|
- `APK_VERSION` overrides the packaged app version.
|
||||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||||
|
|||||||
@@ -5,8 +5,20 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
|||||||
APP_ID ?= org.julianfamily.keepassgo
|
APP_ID ?= org.julianfamily.keepassgo
|
||||||
APK_OUT ?= build/keepassgo.apk
|
APK_OUT ?= build/keepassgo.apk
|
||||||
APK_VERSION ?= 0.1.0.1
|
APK_VERSION ?= 0.1.0.1
|
||||||
|
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
|
||||||
ANDROID_MIN_SDK ?= 28
|
ANDROID_MIN_SDK ?= 28
|
||||||
ANDROID_TARGET_SDK ?= 35
|
ANDROID_TARGET_SDK ?= 35
|
||||||
|
SIGNKEY ?=
|
||||||
|
SIGNPASS ?=
|
||||||
|
|
||||||
|
GOGIO_SIGN_FLAGS :=
|
||||||
|
ifneq ($(strip $(SIGNKEY)),)
|
||||||
|
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
||||||
|
endif
|
||||||
|
ifneq ($(strip $(SIGNPASS)),)
|
||||||
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: apk
|
.PHONY: apk
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@@ -24,6 +36,8 @@ apk: android/keepassgo-android.jar
|
|||||||
go tool gogio -target android \
|
go tool gogio -target android \
|
||||||
-buildmode exe \
|
-buildmode exe \
|
||||||
-appid $(APP_ID) \
|
-appid $(APP_ID) \
|
||||||
|
-ldflags "$(GO_LDFLAGS)" \
|
||||||
|
$(GOGIO_SIGN_FLAGS) \
|
||||||
-o $(APK_OUT) \
|
-o $(APK_OUT) \
|
||||||
-version $(APK_VERSION) \
|
-version $(APK_VERSION) \
|
||||||
-minsdk $(ANDROID_MIN_SDK) \
|
-minsdk $(ANDROID_MIN_SDK) \
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ Desktop build:
|
|||||||
go build ./...
|
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 main.appVersion=v0.0.1" ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arch Linux Package
|
||||||
|
|
||||||
|
An AUR-style package definition for the Linux desktop client lives under:
|
||||||
|
|
||||||
|
- `packaging/archlinux/keepassgo-git/`
|
||||||
|
|
||||||
|
From that directory you can build and install it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
The package installs:
|
||||||
|
|
||||||
|
- `/usr/bin/keepassgo`
|
||||||
|
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||||
|
- application icons under the hicolor theme
|
||||||
|
|
||||||
## Android Packaging
|
## Android Packaging
|
||||||
|
|
||||||
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
if err := lifecycle.Create(vault.Model{
|
if err := lifecycle.Create(vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
t.Fatalf("Create() error = %v", err)
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
|||||||
+69
-69
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
token.Entry([]string{"Root", "API Tokens"}),
|
token.Entry([]string{"Root", "API Tokens"}),
|
||||||
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
testAPITokenEntry(t,
|
testAPITokenEntry(t,
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
|
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
|
||||||
if status.Code(err) != codes.PermissionDenied {
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
}
|
}
|
||||||
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
|||||||
if err := <-errCh; err != nil {
|
if err := <-errCh; err != nil {
|
||||||
t.Fatalf("ListEntries() error = %v", err)
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
|
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
|
||||||
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
|
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
|||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
|||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
|
|||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
|||||||
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||||
BaseUrl: "https://dav.example.com",
|
BaseUrl: "https://dav.example.com",
|
||||||
Path: "vaults/main.kdbx",
|
Path: "vaults/main.kdbx",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "dav-token",
|
Password: "dav-token",
|
||||||
MasterPassword: "correct horse battery staple",
|
MasterPassword: "correct horse battery staple",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Entries[0].Title != "Git Server" {
|
if resp.Entries[0].Title != "Vault Console" {
|
||||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
|
||||||
}
|
}
|
||||||
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
|
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
|
||||||
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
|
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
|
||||||
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
|||||||
|
|
||||||
ctx := tokenContext(defaultTestTokenSecret)
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
||||||
Id: "git-server",
|
Id: "vault-console",
|
||||||
Target: "password",
|
Target: "password",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("CopyEntryField() error = %v", err)
|
t.Fatalf("CopyEntryField() error = %v", err)
|
||||||
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
ctx := tokenContext(defaultTestTokenSecret)
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
||||||
Entry: &keepassgov1.Entry{
|
Entry: &keepassgov1.Entry{
|
||||||
Id: "ha-codex",
|
Id: "surveillance-console",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
Url: "https://lights.julianfamily.org",
|
Url: "https://surveillance.crew.example.invalid",
|
||||||
Fields: map[string]string{
|
Fields: map[string]string{
|
||||||
"X-Role": "lights-admin",
|
"X-Role": "lights-admin",
|
||||||
},
|
},
|
||||||
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
t.Fatalf("UpsertEntry() error = %v", err)
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
if upserted.Entry.Title != "Surveillance Console" {
|
||||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
|
||||||
}
|
}
|
||||||
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
|
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
|
||||||
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
|
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
|
||||||
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := tokenContext(defaultTestTokenSecret)
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
|
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
|
||||||
t.Fatalf("DeleteEntry() error = %v", err)
|
t.Fatalf("DeleteEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
|||||||
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
|
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
|
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RestoreEntry() error = %v", err)
|
t.Fatalf("RestoreEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if restored.Entry.Title != "Git Server" {
|
if restored.Entry.Title != "Vault Console" {
|
||||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
|
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
|
||||||
}
|
}
|
||||||
|
|
||||||
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
|||||||
t.Fatalf("ListEntries() error = %v", err)
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
|
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
|
||||||
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
|
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||||
TemplateId: "website-login",
|
TemplateId: "website-login",
|
||||||
Overrides: &keepassgov1.Entry{
|
Overrides: &keepassgov1.Entry{
|
||||||
Id: "dynadot",
|
Id: "bellagio",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "hunter2",
|
Password: "hunter2",
|
||||||
Url: "https://www.dynadot.com",
|
Url: "https://bellagio.example.invalid",
|
||||||
Fields: map[string]string{
|
Fields: map[string]string{
|
||||||
"Environment": "staging",
|
"Environment": "staging",
|
||||||
},
|
},
|
||||||
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
t.Fatalf("InstantiateTemplate() error = %v", err)
|
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
|
||||||
}
|
}
|
||||||
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
||||||
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
||||||
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := tokenContext(defaultTestTokenSecret)
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
|
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListEntryHistory() error = %v", err)
|
t.Fatalf("ListEntryHistory() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
||||||
Id: "git-server",
|
Id: "vault-console",
|
||||||
HistoryIndex: 0,
|
HistoryIndex: 0,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
|||||||
uploaded := []byte("attachment-content")
|
uploaded := []byte("attachment-content")
|
||||||
|
|
||||||
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
||||||
EntryId: "git-server",
|
EntryId: "vault-console",
|
||||||
Name: "token.txt",
|
Name: "token.txt",
|
||||||
Content: uploaded,
|
Content: uploaded,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("UploadAttachment() error = %v", err)
|
t.Fatalf("UploadAttachment() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListAttachments() error = %v", err)
|
t.Fatalf("ListAttachments() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
||||||
EntryId: "git-server",
|
EntryId: "vault-console",
|
||||||
Name: "token.txt",
|
Name: "token.txt",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
||||||
EntryId: "git-server",
|
EntryId: "vault-console",
|
||||||
Name: "token.txt",
|
Name: "token.txt",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("DeleteAttachment() error = %v", err)
|
t.Fatalf("DeleteAttachment() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListAttachments() error = %v", err)
|
t.Fatalf("ListAttachments() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Fields: map[string]string{
|
Fields: map[string]string{
|
||||||
"X-Role": "automation",
|
"X-Role": "automation",
|
||||||
},
|
},
|
||||||
History: []vault.Entry{
|
History: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server-h1",
|
ID: "vault-console-h1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-0",
|
Password: "token-0",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "ha-codex",
|
ID: "surveillance-console",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
testAPITokenEntry(t,
|
testAPITokenEntry(t,
|
||||||
@@ -1090,9 +1090,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Templates: []vault.Entry{
|
Templates: []vault.Entry{
|
||||||
|
|||||||
+15
-1
@@ -26,6 +26,7 @@ const (
|
|||||||
SectionRecycleBin Section = "recycle-bin"
|
SectionRecycleBin Section = "recycle-bin"
|
||||||
SectionAPITokens Section = "api-tokens"
|
SectionAPITokens Section = "api-tokens"
|
||||||
SectionAPIAudit Section = "api-audit"
|
SectionAPIAudit Section = "api-audit"
|
||||||
|
SectionAbout Section = "about"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CurrentSession interface {
|
type CurrentSession interface {
|
||||||
@@ -61,6 +62,7 @@ type SynchronizableSession interface {
|
|||||||
type AdvancedSynchronizableSession interface {
|
type AdvancedSynchronizableSession interface {
|
||||||
CurrentSession
|
CurrentSession
|
||||||
SynchronizeFromLocal(string) error
|
SynchronizeFromLocal(string) error
|
||||||
|
SynchronizeFromLocalBytes(string, []byte) error
|
||||||
SynchronizeToLocal(string) error
|
SynchronizeToLocal(string) error
|
||||||
SynchronizeFromRemote(webdav.Client, string) error
|
SynchronizeFromRemote(webdav.Client, string) error
|
||||||
SynchronizeToRemote(webdav.Client, string) error
|
SynchronizeToRemote(webdav.Client, string) error
|
||||||
@@ -375,7 +377,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
|||||||
return slices.Clone(model.Templates)
|
return slices.Clone(model.Templates)
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
return slices.Clone(model.RecycleBin)
|
return slices.Clone(model.RecycleBin)
|
||||||
case SectionAPITokens, SectionAPIAudit:
|
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return slices.Clone(model.Entries)
|
return slices.Clone(model.Entries)
|
||||||
@@ -722,6 +724,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||||
|
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not advanced-synchronizable")
|
||||||
|
}
|
||||||
|
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) SynchronizeToLocal(path string) error {
|
func (s *State) SynchronizeToLocal(path string) error {
|
||||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+120
-100
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe", "Internet"},
|
CurrentPath: []string{"Crew", "Internet"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
@@ -39,8 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
titles = append(titles, entry.Title)
|
titles = append(titles, entry.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
|
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
|
||||||
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
|
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +51,14 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "joe-note", Title: "Joe Note", Path: []string{"Joe"}},
|
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe"},
|
CurrentPath: []string{"Crew"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
@@ -70,8 +70,8 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
|||||||
for _, entry := range got {
|
for _, entry := range got {
|
||||||
titles = append(titles, entry.Title)
|
titles = append(titles, entry.Title)
|
||||||
}
|
}
|
||||||
if !slices.Equal(titles, []string{"Joe Note"}) {
|
if !slices.Equal(titles, []string{"Crew Note"}) {
|
||||||
t.Fatalf("visible titles = %v, want only direct entries from Joe", titles)
|
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +171,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe", "Internet"},
|
CurrentPath: []string{"Crew", "Internet"},
|
||||||
SearchQuery: "lights",
|
SearchQuery: "surveillance",
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
@@ -186,7 +186,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
|
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,13 +198,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe"},
|
CurrentPath: []string{"Crew"},
|
||||||
SearchQuery: "missing",
|
SearchQuery: "missing",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
|||||||
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
|
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Joe", len(got))
|
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
RecycleBin: []vault.Entry{
|
RecycleBin: []vault.Entry{
|
||||||
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
|
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -418,13 +418,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe"},
|
CurrentPath: []string{"Crew"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.ChildGroups()
|
got, err := state.ChildGroups()
|
||||||
@@ -470,19 +470,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
|
|||||||
Session: stubSession{
|
Session: stubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CurrentPath: []string{"Joe", "Internet"},
|
CurrentPath: []string{"Crew", "Internet"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.SelectVisibleIndex(1); err != nil {
|
if err := state.SelectVisibleIndex(1); err != nil {
|
||||||
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := state.SelectedEntryID; got != "git-server" {
|
if got := state.SelectedEntryID; got != "vault-console" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.ToggleVisibleIndex(1); err != nil {
|
if err := state.ToggleVisibleIndex(1); err != nil {
|
||||||
@@ -524,14 +524,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
|||||||
sess := &mutableStubSession{
|
sess := &mutableStubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.DeleteSelectedEntry(); err != nil {
|
if err := state.DeleteSelectedEntry(); err != nil {
|
||||||
@@ -546,8 +546,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
|||||||
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
|
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
|
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
|
||||||
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
|
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
@@ -561,7 +561,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
|||||||
sess := &mutableStubSession{
|
sess := &mutableStubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
RecycleBin: []vault.Entry{
|
RecycleBin: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -570,7 +570,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
|||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.RestoreEntry("git-server"); err != nil {
|
if err := state.RestoreEntry("vault-console"); err != nil {
|
||||||
t.Fatalf("RestoreEntry() error = %v", err)
|
t.Fatalf("RestoreEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,8 +579,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
|||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got) != 1 || got[0].ID != "git-server" {
|
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
@@ -600,19 +600,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := vault.Entry{
|
entry := vault.Entry{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
if err := state.UpsertEntry(entry); err != nil {
|
if err := state.UpsertEntry(entry); err != nil {
|
||||||
t.Fatalf("UpsertEntry() error = %v", err)
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := state.SelectedEntryID; got != "git-server" {
|
if got := state.SelectedEntryID; got != "vault-console" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
@@ -620,7 +620,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 1 || got[0].Password != "token-1" {
|
if len(got) != 1 || got[0].Password != "token-1" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
@@ -652,11 +652,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
|
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
|
||||||
ID: "dynadot",
|
ID: "bellagio",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "hunter2",
|
Password: "hunter2",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -667,16 +667,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
|||||||
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
|
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := state.SelectedEntryID; got != "dynadot" {
|
if got := state.SelectedEntryID; got != "bellagio" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
|
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 1 || got[0].ID != "dynadot" {
|
if len(got) != 1 || got[0].ID != "bellagio" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
@@ -743,24 +743,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
|
|||||||
|
|
||||||
sess := &mutableStubSession{model: vault.Model{
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
|
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if duplicate.ID != "git-server-copy" {
|
if duplicate.ID != "vault-console-copy" {
|
||||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
|
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
|
||||||
}
|
}
|
||||||
if state.SelectedEntryID != "git-server-copy" {
|
if state.SelectedEntryID != "vault-console-copy" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
|
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
|
||||||
}
|
}
|
||||||
if len(sess.model.Entries) != 2 {
|
if len(sess.model.Entries) != 2 {
|
||||||
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
||||||
@@ -772,13 +772,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
|||||||
|
|
||||||
sess := &mutableStubSession{model: vault.Model{
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
|
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
|
||||||
@@ -791,8 +791,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
|
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
|
||||||
if len(newPath) != 1 || newPath[0].ID != "git-server" {
|
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
|
||||||
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
|
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
@@ -806,19 +806,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
|||||||
sess := &mutableStubSession{model: vault.Model{
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Password: "new-token",
|
Password: "new-token",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
History: []vault.Entry{
|
History: []vault.Entry{
|
||||||
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.RestoreSelectedEntryVersion(0); err != nil {
|
if err := state.RestoreSelectedEntryVersion(0); err != nil {
|
||||||
@@ -862,7 +862,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
Dirty: true,
|
Dirty: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,7 +891,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
Dirty: true,
|
Dirty: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +942,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
Dirty: true,
|
Dirty: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,14 +970,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
|||||||
sess := &lockableStubSession{
|
sess := &lockableStubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.Lock(); err != nil {
|
if err := state.Lock(); err != nil {
|
||||||
@@ -1000,7 +1000,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
|||||||
sess := &lockableStubSession{
|
sess := &lockableStubSession{
|
||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
locked: true,
|
locked: true,
|
||||||
@@ -1018,8 +1018,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 1 || got[0].ID != "git-server" {
|
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
|
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,7 +1048,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Section: SectionEntries,
|
Section: SectionEntries,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
SearchQuery: "git",
|
SearchQuery: "git",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,7 +1084,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
state := State{
|
state := State{
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
ErrorMessage: "previous error",
|
ErrorMessage: "previous error",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,7 +1128,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
|||||||
|
|
||||||
state := State{
|
state := State{
|
||||||
CurrentPath: []string{"Root"},
|
CurrentPath: []string{"Root"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
state.EnterGroup("Internet")
|
state.EnterGroup("Internet")
|
||||||
@@ -1146,7 +1146,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
|||||||
|
|
||||||
state := State{
|
state := State{
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "git-server",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||||
@@ -1297,7 +1297,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||||
@@ -1313,8 +1313,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
|||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
|
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
|
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
|
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
|
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
|
||||||
@@ -1326,7 +1326,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
|
|||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
|
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
|
||||||
@@ -1337,15 +1337,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
|
|||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveCurrentGroup([]string{"Root", "Joe"}); err != nil {
|
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
|
||||||
t.Fatalf("MoveCurrentGroup() error = %v", err)
|
t.Fatalf("MoveCurrentGroup() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
|
||||||
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath)
|
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
|
||||||
}
|
}
|
||||||
if got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" {
|
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
|
||||||
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got)
|
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
|
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
|
||||||
@@ -1359,7 +1359,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
|
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
|
||||||
@@ -1387,7 +1387,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
|
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
|
||||||
@@ -1413,7 +1413,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
|
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
|
||||||
@@ -1439,7 +1439,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
|
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
|
||||||
@@ -1457,7 +1457,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
|
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
|
||||||
@@ -1483,7 +1483,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
|
|||||||
state := State{
|
state := State{
|
||||||
Session: sess,
|
Session: sess,
|
||||||
CurrentPath: []string{"Root", "Internet"},
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
SelectedEntryID: "dynadot",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
|
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
|
||||||
@@ -1511,8 +1511,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
|
|||||||
func testVaultModel() vault.Model {
|
func testVaultModel() vault.Model {
|
||||||
return vault.Model{
|
return vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
s.changedKey = key
|
s.changedKey = key
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+10
-10
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
|||||||
Username: "joe",
|
Username: "joe",
|
||||||
Password: "secret",
|
Password: "secret",
|
||||||
URL: "https://10.0.2.2:8443/login",
|
URL: "https://10.0.2.2:8443/login",
|
||||||
Path: []string{"Joe", "Internet"},
|
Path: []string{"Crew", "Internet"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "two",
|
ID: "two",
|
||||||
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
|||||||
Title: "Bare Host",
|
Title: "Bare Host",
|
||||||
Username: "user",
|
Username: "user",
|
||||||
Password: "pass",
|
Password: "pass",
|
||||||
URL: "lights.julianfamily.org",
|
URL: "surveillance.crew.example.invalid",
|
||||||
Fields: map[string]string{
|
Fields: map[string]string{
|
||||||
"AndroidApp1": "androidapp://com.lights.mobile",
|
"AndroidApp1": "androidapp://com.lights.mobile",
|
||||||
"KP2A_URL_1": "https://lights.julianfamily.org/account",
|
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
|||||||
if got.Entries[0].Host != "10.0.2.2" {
|
if got.Entries[0].Host != "10.0.2.2" {
|
||||||
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
|
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
|
||||||
}
|
}
|
||||||
if got.Entries[1].Host != "lights.julianfamily.org" {
|
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
|
||||||
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
|
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
|
||||||
}
|
}
|
||||||
if len(got.Entries[1].Targets) != 3 {
|
if len(got.Entries[1].Targets) != 3 {
|
||||||
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
|
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
|
||||||
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
|
|||||||
Title: "Host A",
|
Title: "Host A",
|
||||||
Username: "first",
|
Username: "first",
|
||||||
Password: "secret1",
|
Password: "secret1",
|
||||||
URL: "https://lights.julianfamily.org/",
|
URL: "https://surveillance.crew.example.invalid/",
|
||||||
Host: "lights.julianfamily.org",
|
Host: "surveillance.crew.example.invalid",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "two",
|
ID: "two",
|
||||||
Title: "Host B",
|
Title: "Host B",
|
||||||
Username: "second",
|
Username: "second",
|
||||||
Password: "secret2",
|
Password: "secret2",
|
||||||
URL: "https://lights.julianfamily.org/",
|
URL: "https://surveillance.crew.example.invalid/",
|
||||||
Host: "lights.julianfamily.org",
|
Host: "surveillance.crew.example.invalid",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok {
|
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
|
||||||
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
|
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
DefaultAppID = "org.julianfamily.keepassgo"
|
DefaultAppID = "org.julianfamily.keepassgo"
|
||||||
DefaultAPKOut = "build/keepassgo.apk"
|
DefaultAPKOut = "build/keepassgo.apk"
|
||||||
DefaultVersion = "0.1.0.1"
|
DefaultVersion = "0.1.0.1"
|
||||||
|
DefaultLdflags = "-X main.appVersion=dev"
|
||||||
DefaultMinSDK = "28"
|
DefaultMinSDK = "28"
|
||||||
DefaultTargetSDK = "35"
|
DefaultTargetSDK = "35"
|
||||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||||
@@ -25,6 +26,7 @@ type Config struct {
|
|||||||
AppID string
|
AppID string
|
||||||
APKOut string
|
APKOut string
|
||||||
Version string
|
Version string
|
||||||
|
Ldflags string
|
||||||
MinSDK string
|
MinSDK string
|
||||||
TargetSDK string
|
TargetSDK string
|
||||||
IconPath string
|
IconPath string
|
||||||
@@ -38,6 +40,7 @@ func DefaultConfig() Config {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
IconPath: DefaultIconPath,
|
IconPath: DefaultIconPath,
|
||||||
@@ -49,6 +52,7 @@ func (c Config) GogioArgs() []string {
|
|||||||
"-target", "android",
|
"-target", "android",
|
||||||
"-buildmode", "exe",
|
"-buildmode", "exe",
|
||||||
"-appid", c.AppID,
|
"-appid", c.AppID,
|
||||||
|
"-ldflags", c.Ldflags,
|
||||||
"-o", c.APKOut,
|
"-o", c.APKOut,
|
||||||
"-version", c.Version,
|
"-version", c.Version,
|
||||||
"-minsdk", c.MinSDK,
|
"-minsdk", c.MinSDK,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
|||||||
"-target", "android",
|
"-target", "android",
|
||||||
"-buildmode", "exe",
|
"-buildmode", "exe",
|
||||||
"-appid", DefaultAppID,
|
"-appid", DefaultAppID,
|
||||||
|
"-ldflags", DefaultLdflags,
|
||||||
"-o", DefaultAPKOut,
|
"-o", DefaultAPKOut,
|
||||||
"-version", DefaultVersion,
|
"-version", DefaultVersion,
|
||||||
"-minsdk", DefaultMinSDK,
|
"-minsdk", DefaultMinSDK,
|
||||||
@@ -52,6 +53,7 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
IconPath: filepath.Join(root, "icon.png"),
|
IconPath: filepath.Join(root, "icon.png"),
|
||||||
@@ -77,6 +79,7 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
|||||||
target Target
|
target Target
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "username", target: TargetUsername, want: "joejulian"},
|
{name: "username", target: TargetUsername, want: "dannyocean"},
|
||||||
{name: "password", target: TargetPassword, want: "token-1"},
|
{name: "password", target: TargetPassword, want: "token-1"},
|
||||||
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
|
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
|
||||||
}
|
}
|
||||||
|
|
||||||
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, "git-server", tt.target); err != nil {
|
if err := service.Copy(model, "vault-console", tt.target); err != nil {
|
||||||
t.Fatalf("Copy() error = %v", err)
|
t.Fatalf("Copy() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
|
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
|
||||||
}
|
}
|
||||||
err = service.Copy(model, "git-server", Target("unsupported"))
|
err = service.Copy(model, "vault-console", Target("unsupported"))
|
||||||
if !errors.Is(err, ErrUnsupportedTarget) {
|
if !errors.Is(err, ErrUnsupportedTarget) {
|
||||||
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
|
|||||||
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
|
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "git-server", Password: "token-1"},
|
{ID: "vault-console", Password: "token-1"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := service.Copy(model, "git-server", TargetPassword)
|
err := service.Copy(model, "vault-console", TargetPassword)
|
||||||
if !errors.Is(err, ErrWriteFailed) {
|
if !errors.Is(err, ErrWriteFailed) {
|
||||||
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
gioclipboard "gioui.org/io/clipboard"
|
||||||
|
"gioui.org/layout"
|
||||||
|
|
||||||
|
appclipboard "git.julianfamily.org/keepassgo/clipboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type clipboardCommandWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []string
|
||||||
|
invalidate func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPlatformClipboardWriter(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
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
writer := newPlatformClipboardWriter("android", nil)
|
||||||
|
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||||
|
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
||||||
|
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
|||||||
- list navigation
|
- list navigation
|
||||||
- search focus
|
- search focus
|
||||||
- new-entry focus transitions
|
- new-entry focus transitions
|
||||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go).
|
- 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
|
## Current screen-reader boundary
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.8.0
|
gioui.org v0.8.0
|
||||||
|
gioui.org/x v0.8.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||||
@@ -18,6 +19,7 @@ require (
|
|||||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||||
gioui.org/cmd v0.8.0 // indirect
|
gioui.org/cmd v0.8.0 // indirect
|
||||||
gioui.org/shader v1.0.8 // indirect
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
||||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||||
github.com/Antonboom/errname v1.0.0 // indirect
|
github.com/Antonboom/errname v1.0.0 // indirect
|
||||||
@@ -74,6 +76,7 @@ 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
|
||||||
@@ -192,7 +195,7 @@ require (
|
|||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
go.uber.org/zap v1.24.0 // indirect
|
go.uber.org/zap v1.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||||
golang.org/x/image v0.37.0 // indirect
|
golang.org/x/image v0.37.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
|||||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
|
||||||
|
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
||||||
|
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
||||||
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
||||||
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||||
@@ -224,6 +228,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
|||||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
@@ -669,8 +675,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
|||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
|
||||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
|
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
|
||||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
"gioui.org/x/explorer"
|
||||||
"git.julianfamily.org/keepassgo/api"
|
"git.julianfamily.org/keepassgo/api"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apiaudit"
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
@@ -41,6 +43,15 @@ import (
|
|||||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var appVersion = "dev"
|
||||||
|
|
||||||
|
func currentAppVersion() string {
|
||||||
|
if strings.TrimSpace(appVersion) == "" {
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(appVersion)
|
||||||
|
}
|
||||||
|
|
||||||
type entry = vault.Entry
|
type entry = vault.Entry
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -200,6 +211,7 @@ const (
|
|||||||
type ui struct {
|
type ui struct {
|
||||||
mode string
|
mode string
|
||||||
theme *material.Theme
|
theme *material.Theme
|
||||||
|
fileExplorer *explorer.Explorer
|
||||||
logoHorizontal paint.ImageOp
|
logoHorizontal paint.ImageOp
|
||||||
splashSquare paint.ImageOp
|
splashSquare paint.ImageOp
|
||||||
search widget.Editor
|
search widget.Editor
|
||||||
@@ -331,6 +343,7 @@ type ui struct {
|
|||||||
showRecycle widget.Clickable
|
showRecycle widget.Clickable
|
||||||
showAPITokens widget.Clickable
|
showAPITokens widget.Clickable
|
||||||
showAPIAudit widget.Clickable
|
showAPIAudit widget.Clickable
|
||||||
|
showAbout widget.Clickable
|
||||||
showLocalLifecycle widget.Clickable
|
showLocalLifecycle widget.Clickable
|
||||||
showRemoteLifecycle widget.Clickable
|
showRemoteLifecycle widget.Clickable
|
||||||
showSyncLocal widget.Clickable
|
showSyncLocal widget.Clickable
|
||||||
@@ -384,6 +397,9 @@ type ui struct {
|
|||||||
disableAPIToken widget.Clickable
|
disableAPIToken widget.Clickable
|
||||||
revokeAPIToken widget.Clickable
|
revokeAPIToken widget.Clickable
|
||||||
deleteAPIToken widget.Clickable
|
deleteAPIToken widget.Clickable
|
||||||
|
useCurrentGroupForPolicy widget.Clickable
|
||||||
|
useSelectedEntryForPolicy widget.Clickable
|
||||||
|
clearAPIPolicyTarget widget.Clickable
|
||||||
addAPIPolicyRule widget.Clickable
|
addAPIPolicyRule widget.Clickable
|
||||||
phoneSplit widget.Float
|
phoneSplit widget.Float
|
||||||
splitDrag gesture.Drag
|
splitDrag gesture.Drag
|
||||||
@@ -396,6 +412,7 @@ type ui struct {
|
|||||||
copyIcon *widget.Icon
|
copyIcon *widget.Icon
|
||||||
expandMoreIcon *widget.Icon
|
expandMoreIcon *widget.Icon
|
||||||
expandLessIcon *widget.Icon
|
expandLessIcon *widget.Icon
|
||||||
|
chevronRightIcon *widget.Icon
|
||||||
chevronDownIcon *widget.Icon
|
chevronDownIcon *widget.Icon
|
||||||
settingsIcon *widget.Icon
|
settingsIcon *widget.Icon
|
||||||
menuIcon *widget.Icon
|
menuIcon *widget.Icon
|
||||||
@@ -405,6 +422,8 @@ type ui struct {
|
|||||||
lifecycleMode string
|
lifecycleMode string
|
||||||
syncSourceMode syncSourceMode
|
syncSourceMode syncSourceMode
|
||||||
syncDirection syncDirection
|
syncDirection syncDirection
|
||||||
|
syncLocalImportName string
|
||||||
|
syncLocalImportContent []byte
|
||||||
syncLocalPath widget.Editor
|
syncLocalPath widget.Editor
|
||||||
syncRemoteBaseURL widget.Editor
|
syncRemoteBaseURL widget.Editor
|
||||||
syncRemotePath widget.Editor
|
syncRemotePath widget.Editor
|
||||||
@@ -413,6 +432,7 @@ type ui struct {
|
|||||||
syncDialogOpen bool
|
syncDialogOpen bool
|
||||||
syncMenuOpen bool
|
syncMenuOpen bool
|
||||||
mainMenuOpen bool
|
mainMenuOpen bool
|
||||||
|
selectedRemoteConnection bool
|
||||||
securityDialogOpen bool
|
securityDialogOpen bool
|
||||||
remotePrefsDialogOpen bool
|
remotePrefsDialogOpen bool
|
||||||
showSyncPassword bool
|
showSyncPassword bool
|
||||||
@@ -615,6 +635,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
||||||
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
|
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
|
||||||
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
|
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
|
||||||
|
u.chevronRightIcon, _ = widget.NewIcon(icons.NavigationChevronRight)
|
||||||
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
|
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
|
||||||
u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings)
|
u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings)
|
||||||
u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu)
|
u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu)
|
||||||
@@ -645,13 +666,21 @@ func (u *ui) filter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.visible = visible
|
u.visible = visible
|
||||||
if len(u.entryClicks) < len(u.visible) {
|
u.ensureEntryClicks()
|
||||||
u.entryClicks = make([]widget.Clickable, len(u.visible))
|
}
|
||||||
|
|
||||||
|
func (u *ui) ensureEntryClicks() {
|
||||||
|
if len(u.entryClicks) >= len(u.visible) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
next := make([]widget.Clickable, len(u.visible))
|
||||||
|
copy(next, u.entryClicks)
|
||||||
|
u.entryClicks = next
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
|
func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
|
||||||
visible := append([]entry(nil), u.visible...)
|
visible := append([]entry(nil), u.visible...)
|
||||||
|
u.ensureEntryClicks()
|
||||||
clicks := make([]*widget.Clickable, len(visible))
|
clicks := make([]*widget.Clickable, len(visible))
|
||||||
for i := range visible {
|
for i := range visible {
|
||||||
clicks[i] = &u.entryClicks[i]
|
clicks[i] = &u.entryClicks[i]
|
||||||
@@ -659,6 +688,21 @@ func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
|
|||||||
return visible, clicks
|
return visible, clicks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) searchPlaceholder() string {
|
||||||
|
switch u.state.Section {
|
||||||
|
case appstate.SectionAPITokens:
|
||||||
|
return "Search API tokens"
|
||||||
|
case appstate.SectionAPIAudit:
|
||||||
|
return "Search audit log"
|
||||||
|
case appstate.SectionRecycleBin:
|
||||||
|
return "Search recycle bin"
|
||||||
|
case appstate.SectionAbout:
|
||||||
|
return "Search disabled on About"
|
||||||
|
default:
|
||||||
|
return "Search vault"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func defaultStatePaths(stateDir string) statePaths {
|
func defaultStatePaths(stateDir string) statePaths {
|
||||||
baseDir := strings.TrimSpace(stateDir)
|
baseDir := strings.TrimSpace(stateDir)
|
||||||
if baseDir == "" {
|
if baseDir == "" {
|
||||||
@@ -783,6 +827,14 @@ func (u *ui) showAPIAuditSection() {
|
|||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) showAboutSection() {
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.rememberEntriesSectionState()
|
||||||
|
u.state.ShowSection(appstate.SectionAbout)
|
||||||
|
u.mainMenuOpen = false
|
||||||
|
u.filter()
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) returnToMainEntries() {
|
func (u *ui) returnToMainEntries() {
|
||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
@@ -1190,6 +1242,19 @@ func (u *ui) openAdvancedSyncDialog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||||
switch mode {
|
switch mode {
|
||||||
case syncSourceRemote:
|
case syncSourceRemote:
|
||||||
@@ -1229,6 +1294,12 @@ func (u *ui) advancedSyncFromAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
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())
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return errors.New(errVaultPathRequired)
|
return errors.New(errVaultPathRequired)
|
||||||
@@ -1243,6 +1314,37 @@ func (u *ui) advancedSyncFromAction() error {
|
|||||||
return nil
|
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 (u *ui) advancedSyncToAction() error {
|
func (u *ui) advancedSyncToAction() error {
|
||||||
switch u.syncSourceMode {
|
switch u.syncSourceMode {
|
||||||
case syncSourceRemote:
|
case syncSourceRemote:
|
||||||
@@ -1671,12 +1773,16 @@ func (u *ui) restoreStartupLifecycleTarget() {
|
|||||||
func (u *ui) hasSelectedLifecycleTarget() bool {
|
func (u *ui) hasSelectedLifecycleTarget() bool {
|
||||||
switch strings.TrimSpace(u.lifecycleMode) {
|
switch strings.TrimSpace(u.lifecycleMode) {
|
||||||
case "remote":
|
case "remote":
|
||||||
return strings.TrimSpace(u.remoteBaseURL.Text()) != "" && strings.TrimSpace(u.remotePath.Text()) != ""
|
return u.hasSelectedRemoteTarget()
|
||||||
default:
|
default:
|
||||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) hasSelectedRemoteTarget() bool {
|
||||||
|
return u.selectedRemoteConnection
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) latestRecentVault() (string, time.Time) {
|
func (u *ui) latestRecentVault() (string, time.Time) {
|
||||||
for _, path := range u.recentVaults {
|
for _, path := range u.recentVaults {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
@@ -1687,6 +1793,58 @@ func (u *ui) latestRecentVault() (string, time.Time) {
|
|||||||
return "", time.Time{}
|
return "", time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) hasSelectedVaultPath() bool {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) showLocalVaultChooser() bool {
|
||||||
|
return u.lifecycleMode != "local" || !u.hasSelectedVaultPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) showRemoteConnectionChooser() bool {
|
||||||
|
return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) switchToLifecycleSelection(mode string) {
|
||||||
|
u.state.Session = &session.Manager{}
|
||||||
|
u.state.CurrentPath = nil
|
||||||
|
u.state.SelectedEntryID = ""
|
||||||
|
u.state.Section = appstate.SectionEntries
|
||||||
|
u.state.Dirty = false
|
||||||
|
u.state.ErrorMessage = ""
|
||||||
|
u.state.StatusMessage = ""
|
||||||
|
u.loadingMessage = ""
|
||||||
|
u.loadingActionLabel = ""
|
||||||
|
u.lastLifecycleAction = ""
|
||||||
|
u.lifecycleMode = mode
|
||||||
|
u.editingEntry = false
|
||||||
|
u.currentPath = nil
|
||||||
|
u.syncedPath = nil
|
||||||
|
u.clearMasterPassword()
|
||||||
|
u.keyFilePath.SetText("")
|
||||||
|
u.search.SetText("")
|
||||||
|
switch mode {
|
||||||
|
case "remote":
|
||||||
|
u.vaultPath.SetText("")
|
||||||
|
u.remoteBaseURL.SetText("")
|
||||||
|
u.remotePath.SetText("")
|
||||||
|
u.remoteUsername.SetText("")
|
||||||
|
u.remotePassword.SetText("")
|
||||||
|
u.rememberRemoteAuth.Value = false
|
||||||
|
u.selectedRemoteConnection = false
|
||||||
|
default:
|
||||||
|
u.vaultPath.SetText("")
|
||||||
|
u.remoteBaseURL.SetText("")
|
||||||
|
u.remotePath.SetText("")
|
||||||
|
u.remoteUsername.SetText("")
|
||||||
|
u.remotePassword.SetText("")
|
||||||
|
u.rememberRemoteAuth.Value = false
|
||||||
|
u.selectedRemoteConnection = false
|
||||||
|
}
|
||||||
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
||||||
|
u.filter()
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
|
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
|
||||||
for _, record := range u.recentRemotes {
|
for _, record := range u.recentRemotes {
|
||||||
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
|
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
|
||||||
@@ -1730,6 +1888,7 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
|
|||||||
u.remotePassword.SetText(record.Password)
|
u.remotePassword.SetText(record.Password)
|
||||||
u.remotePassword.Mask = '•'
|
u.remotePassword.Mask = '•'
|
||||||
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
|
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
|
||||||
|
u.selectedRemoteConnection = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) remotePreferencesCurrentSummary() string {
|
func (u *ui) remotePreferencesCurrentSummary() string {
|
||||||
@@ -2582,7 +2741,7 @@ func (u *ui) listEmptyState() emptyState {
|
|||||||
case appstate.SectionAPITokens:
|
case appstate.SectionAPITokens:
|
||||||
return emptyState{
|
return emptyState{
|
||||||
Title: "No matching API tokens",
|
Title: "No matching API tokens",
|
||||||
Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search vault to find a token by name, client, or expiration.", query),
|
Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query),
|
||||||
}
|
}
|
||||||
case appstate.SectionAPIAudit:
|
case appstate.SectionAPIAudit:
|
||||||
return emptyState{
|
return emptyState{
|
||||||
@@ -2617,6 +2776,11 @@ func (u *ui) listEmptyState() emptyState {
|
|||||||
Title: "No API audit events yet",
|
Title: "No API audit events yet",
|
||||||
Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.",
|
Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.",
|
||||||
}
|
}
|
||||||
|
case appstate.SectionAbout:
|
||||||
|
return emptyState{
|
||||||
|
Title: "About KeePassGO",
|
||||||
|
Body: "Product details, compatibility notes, and platform targets appear in the detail pane.",
|
||||||
|
}
|
||||||
case appstate.SectionTemplates:
|
case appstate.SectionTemplates:
|
||||||
return emptyState{
|
return emptyState{
|
||||||
Title: "Templates unavailable",
|
Title: "Templates unavailable",
|
||||||
@@ -2654,7 +2818,9 @@ func (u *ui) detailPlaceholderMessage() string {
|
|||||||
case appstate.SectionAPITokens:
|
case appstate.SectionAPITokens:
|
||||||
return "Select an API token, issue a new one, or search to narrow the list."
|
return "Select an API token, issue a new one, or search to narrow the list."
|
||||||
case appstate.SectionAPIAudit:
|
case appstate.SectionAPIAudit:
|
||||||
return "Select an audit event to inspect it, or use Search vault or the quick filters above."
|
return "Select an audit event to inspect it, or use Search audit log or the quick filters above."
|
||||||
|
case appstate.SectionAbout:
|
||||||
|
return "Review the product overview, platform support, and compatibility goals."
|
||||||
case appstate.SectionTemplates:
|
case appstate.SectionTemplates:
|
||||||
return "Select a template or start a reusable entry."
|
return "Select a template or start a reusable entry."
|
||||||
case appstate.SectionRecycleBin:
|
case appstate.SectionRecycleBin:
|
||||||
@@ -2872,6 +3038,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
u.showAPIAuditSection()
|
u.showAPIAuditSection()
|
||||||
}
|
}
|
||||||
|
for u.showAbout.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
|
u.showAboutSection()
|
||||||
|
}
|
||||||
for u.showLocalLifecycle.Clicked(gtx) {
|
for u.showLocalLifecycle.Clicked(gtx) {
|
||||||
if u.lifecycleBusy() {
|
if u.lifecycleBusy() {
|
||||||
continue
|
continue
|
||||||
@@ -2884,6 +3054,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.lifecycleMode = "remote"
|
u.lifecycleMode = "remote"
|
||||||
|
u.selectedRemoteConnection = false
|
||||||
u.requestMasterPassFocus = true
|
u.requestMasterPassFocus = true
|
||||||
}
|
}
|
||||||
for u.toggleLifecycleAdvanced.Clicked(gtx) {
|
for u.toggleLifecycleAdvanced.Clicked(gtx) {
|
||||||
@@ -2986,6 +3157,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
for u.addAPIPolicyRule.Clicked(gtx) {
|
for u.addAPIPolicyRule.Clicked(gtx) {
|
||||||
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
||||||
}
|
}
|
||||||
|
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
||||||
|
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
||||||
|
}
|
||||||
|
for u.useSelectedEntryForPolicy.Clicked(gtx) {
|
||||||
|
u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction)
|
||||||
|
}
|
||||||
|
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
||||||
|
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
||||||
|
}
|
||||||
for i := range u.apiPolicyRemoves {
|
for i := range u.apiPolicyRemoves {
|
||||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
@@ -3025,7 +3205,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
||||||
}
|
}
|
||||||
for u.pickSyncLocalPath.Clicked(gtx) {
|
for u.pickSyncLocalPath.Clicked(gtx) {
|
||||||
u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) })
|
u.startChooseSyncLocalSourceAction()
|
||||||
}
|
}
|
||||||
for i := range u.recentVaultClicks {
|
for i := range u.recentVaultClicks {
|
||||||
for u.recentVaultClicks[i].Clicked(gtx) {
|
for u.recentVaultClicks[i].Clicked(gtx) {
|
||||||
@@ -3055,6 +3235,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.lifecycleBusy() {
|
if u.lifecycleBusy() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if u.shouldUseLockedSinglePane() {
|
||||||
|
u.switchToLifecycleSelection("local")
|
||||||
|
continue
|
||||||
|
}
|
||||||
u.vaultPath.SetText("")
|
u.vaultPath.SetText("")
|
||||||
u.state.ErrorMessage = ""
|
u.state.ErrorMessage = ""
|
||||||
u.state.StatusMessage = ""
|
u.state.StatusMessage = ""
|
||||||
@@ -3064,6 +3248,11 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.lifecycleBusy() {
|
if u.lifecycleBusy() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if u.shouldUseLockedSinglePane() {
|
||||||
|
u.switchToLifecycleSelection("remote")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.selectedRemoteConnection = false
|
||||||
u.remoteBaseURL.SetText("")
|
u.remoteBaseURL.SetText("")
|
||||||
u.remotePath.SetText("")
|
u.remotePath.SetText("")
|
||||||
u.remoteUsername.SetText("")
|
u.remoteUsername.SetText("")
|
||||||
@@ -4039,6 +4228,10 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|||||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showAbout, "About")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||||
}),
|
}),
|
||||||
@@ -4046,6 +4239,70 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||||
|
rows := []layout.Widget{
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(22), "KeePassGO")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(15), "A KeePass-compatible password manager built in Go.")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
},
|
||||||
|
layout.Spacer{Height: unit.Dp(14)}.Layout,
|
||||||
|
aboutFact(u.theme, "Compatibility", "KeePass and KDBX interoperability", "Designed to coexist with desktop KeePass and KeePass2Android workflows."),
|
||||||
|
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||||
|
aboutFact(u.theme, "Platforms", "Windows and Linux first, Android supported", "Desktop remains the primary product surface while Android stays compatible."),
|
||||||
|
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||||
|
aboutFact(u.theme, "Sync", "Local files and direct WebDAV", "Remote-file workflows are first-class and avoid browser-stack dependencies."),
|
||||||
|
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||||
|
aboutFact(u.theme, "Programmatic Access", "Secure local gRPC API", "Built for trusted clients such as browser extensions and automation."),
|
||||||
|
layout.Spacer{Height: unit.Dp(14)}.Layout,
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), "Version")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion())
|
||||||
|
lbl.Color = u.theme.Palette.Fg
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
return rows[i](gtx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aboutFact(theme *material.Theme, title, primary, secondary string) layout.Widget {
|
||||||
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(theme, unit.Sp(12), strings.ToUpper(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(theme, unit.Sp(16), primary)
|
||||||
|
lbl.Color = theme.Palette.Fg
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(theme, unit.Sp(13), secondary)
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||||
label := "Sync"
|
label := "Sync"
|
||||||
spacing := unit.Dp(4)
|
spacing := unit.Dp(4)
|
||||||
@@ -4156,32 +4413,28 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
visibleEntries, entryClicks := u.visibleEntrySnapshot()
|
visibleEntries, entryClicks := u.visibleEntrySnapshot()
|
||||||
rows := make([]layout.Widget, 0, 16+len(visibleEntries))
|
rows := make([]layout.Widget, 0, 16+len(visibleEntries))
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
if u.state.Section != appstate.SectionAbout {
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
editor := material.Editor(u.theme, &u.search, "Search vault")
|
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||||
editor.Color = u.theme.Palette.Fg
|
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
||||||
editor.HintColor = mutedColor
|
editor.Color = u.theme.Palette.Fg
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
editor.HintColor = mutedColor
|
||||||
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
})
|
||||||
})
|
}
|
||||||
if !u.isVaultLocked() {
|
if !u.isVaultLocked() {
|
||||||
rows = append(rows, u.navigationHeader)
|
rows = append(rows, u.navigationHeader)
|
||||||
if u.state.Section == appstate.SectionEntries {
|
if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout {
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !u.isVaultLocked() && u.state.Section == appstate.SectionRecycleBin {
|
|
||||||
rows = append(rows, u.recycleBinSectionNotice)
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Spacer{Height: spacing}.Layout(gtx)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
|
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
|
||||||
rows = append(rows, u.pathBar)
|
rows = append(rows, u.pathBar)
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -4206,6 +4459,8 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return btn.Layout(gtx)
|
return btn.Layout(gtx)
|
||||||
case appstate.SectionAPITokens:
|
case appstate.SectionAPITokens:
|
||||||
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
||||||
|
case appstate.SectionAbout:
|
||||||
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||||
default:
|
default:
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
@@ -4219,6 +4474,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
rows = append(rows, u.apiTokenListPanel)
|
rows = append(rows, u.apiTokenListPanel)
|
||||||
case u.state.Section == appstate.SectionAPIAudit:
|
case u.state.Section == appstate.SectionAPIAudit:
|
||||||
rows = append(rows, u.apiAuditListPanel)
|
rows = append(rows, u.apiAuditListPanel)
|
||||||
|
case u.state.Section == appstate.SectionAbout:
|
||||||
case len(visibleEntries) == 0:
|
case len(visibleEntries) == 0:
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||||
@@ -4246,17 +4502,17 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||||
|
}
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return u.recycleBinSectionNotice(gtx)
|
return layout.Spacer{Height: spacing}.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: func() unit.Dp {
|
|
||||||
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return spacing
|
|
||||||
}()}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
@@ -4279,11 +4535,14 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
if u.mode == "phone" {
|
if u.mode == "phone" {
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
}
|
}
|
||||||
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||||
editor := material.Editor(u.theme, &u.search, "Search vault")
|
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
|
||||||
editor.Color = u.theme.Palette.Fg
|
editor.Color = u.theme.Palette.Fg
|
||||||
editor.HintColor = mutedColor
|
editor.HintColor = mutedColor
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||||
@@ -4291,6 +4550,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
if u.isVaultLocked() {
|
if u.isVaultLocked() {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
@@ -4316,6 +4578,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.state.Section == appstate.SectionAPIAudit {
|
if u.state.Section == appstate.SectionAPIAudit {
|
||||||
return u.apiAuditListPanel(gtx)
|
return u.apiAuditListPanel(gtx)
|
||||||
}
|
}
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
if len(u.visible) == 0 {
|
if len(u.visible) == 0 {
|
||||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||||
}
|
}
|
||||||
@@ -4331,13 +4596,23 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
|
|
||||||
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
|
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
|
||||||
if u.mode == "phone" {
|
if u.mode == "phone" {
|
||||||
if u.state.Section != appstate.SectionEntries {
|
if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(18), "About")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}
|
||||||
return u.groupControlsDisclosure(gtx)
|
return u.groupControlsDisclosure(gtx)
|
||||||
}
|
}
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(18), "About")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}
|
||||||
return u.sectionBar(gtx)
|
return u.sectionBar(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -4612,7 +4887,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|||||||
_ = panel
|
_ = panel
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
||||||
if u.isVaultLocked() {
|
if u.isVaultLocked() {
|
||||||
summary := u.currentVaultSummary()
|
|
||||||
return []layout.FlexChild{
|
return []layout.FlexChild{
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
|
lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
|
||||||
@@ -4625,47 +4899,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if strings.TrimSpace(summary.Title) == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "UNLOCK TARGET")
|
|
||||||
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(15), summary.Title)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if strings.TrimSpace(summary.Detail) == "" || summary.Detail == summary.Title {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), summary.Detail)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if strings.TrimSpace(summary.Context) == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), summary.Context)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
layout.Rigid(u.unlockPanel),
|
layout.Rigid(u.unlockPanel),
|
||||||
}
|
}
|
||||||
@@ -4680,6 +4913,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Flexed(1, u.apiAuditDetailPanel),
|
layout.Flexed(1, u.apiAuditDetailPanel),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if u.state.Section == appstate.SectionAbout {
|
||||||
|
return []layout.FlexChild{
|
||||||
|
layout.Flexed(1, u.aboutDetailPanel),
|
||||||
|
}
|
||||||
|
}
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok && !u.editingEntry {
|
if !ok && !u.editingEntry {
|
||||||
return []layout.FlexChild{
|
return []layout.FlexChild{
|
||||||
@@ -5392,32 +5630,22 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
|
|||||||
if atRoot {
|
if atRoot {
|
||||||
u.phoneGroupBrowserExpanded = true
|
u.phoneGroupBrowserExpanded = true
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
children := make([]layout.FlexChild, 0, len(groups))
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
for i := range groups {
|
||||||
if len(groups) == 0 {
|
idx := i
|
||||||
return layout.Dimensions{}
|
name := groups[i]
|
||||||
}
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
maxY := gtx.Dp(unit.Dp(168))
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
if gtx.Constraints.Max.Y > maxY {
|
for u.groupClicks[idx].Clicked(gtx) {
|
||||||
gtx.Constraints.Max.Y = maxY
|
u.state.EnterGroup(name)
|
||||||
}
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
|
u.filter()
|
||||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
}
|
||||||
}
|
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
||||||
return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions {
|
|
||||||
idx := i
|
|
||||||
name := groups[i]
|
|
||||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
for u.groupClicks[idx].Clicked(gtx) {
|
|
||||||
u.state.EnterGroup(name)
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.filter()
|
|
||||||
}
|
|
||||||
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}),
|
}))
|
||||||
)
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -5741,10 +5969,6 @@ func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
|
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
|
||||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -5850,7 +6074,9 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
var ops op.Ops
|
var ops op.Ops
|
||||||
manager := &session.Manager{}
|
manager := &session.Manager{}
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
ui := newUIWithSession(mode, manager, paths)
|
||||||
|
ui.fileExplorer = explorer.NewExplorer(w)
|
||||||
ui.invalidate = w.Invalidate
|
ui.invalidate = w.Invalidate
|
||||||
|
ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||||
@@ -5863,6 +6089,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
e := w.Event()
|
e := w.Event()
|
||||||
|
ui.fileExplorer.ListenEvents(e)
|
||||||
switch e := e.(type) {
|
switch e := e.(type) {
|
||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
return e.Err
|
return e.Err
|
||||||
@@ -5870,6 +6097,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
gtx := app.NewContext(&ops, e)
|
gtx := app.NewContext(&ops, e)
|
||||||
ui.processBackgroundActions()
|
ui.processBackgroundActions()
|
||||||
ui.layout(gtx)
|
ui.layout(gtx)
|
||||||
|
processClipboardWrites(gtx, ui.clipboardWriter)
|
||||||
e.Frame(gtx.Ops)
|
e.Frame(gtx.Ops)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+722
-279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
pkgbase = keepassgo-git
|
||||||
|
pkgdesc = KeePass-compatible password manager written in Go
|
||||||
|
pkgver = r160.5fa79bd
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = custom
|
||||||
|
makedepends = git
|
||||||
|
makedepends = go
|
||||||
|
makedepends = pkgconf
|
||||||
|
depends = glibc
|
||||||
|
depends = hicolor-icon-theme
|
||||||
|
depends = libx11
|
||||||
|
depends = libxcursor
|
||||||
|
depends = libxfixes
|
||||||
|
depends = libxkbcommon
|
||||||
|
depends = libxkbcommon-x11
|
||||||
|
depends = mesa
|
||||||
|
depends = wayland
|
||||||
|
provides = keepassgo
|
||||||
|
conflicts = keepassgo
|
||||||
|
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = keepassgo-git
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
pkgname=keepassgo-git
|
||||||
|
pkgver=r0.0000000
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='KeePass-compatible password manager written in Go'
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
url='https://git.julianfamily.org/joejulian/keepassgo'
|
||||||
|
license=('custom')
|
||||||
|
depends=(
|
||||||
|
'glibc'
|
||||||
|
'hicolor-icon-theme'
|
||||||
|
'libx11'
|
||||||
|
'libxcursor'
|
||||||
|
'libxfixes'
|
||||||
|
'libxkbcommon'
|
||||||
|
'libxkbcommon-x11'
|
||||||
|
'mesa'
|
||||||
|
'wayland'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'go'
|
||||||
|
'pkgconf'
|
||||||
|
)
|
||||||
|
provides=('keepassgo')
|
||||||
|
conflicts=('keepassgo')
|
||||||
|
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
_repo_dir() {
|
||||||
|
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
||||||
|
printf '%s\n' "${srcdir}/keepassgo"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${startdir}/../../.." || exit 1
|
||||||
|
pwd
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$(_repo_dir)"
|
||||||
|
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$(_repo_dir)"
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOFLAGS="-trimpath"
|
||||||
|
local app_version
|
||||||
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
|
go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo .
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
|
install -Dm644 assets/keepassgo-icon.png \
|
||||||
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
|
install -Dm644 assets/keepassgo-icon.svg \
|
||||||
|
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
|
||||||
|
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
|
||||||
|
"${pkgdir}/usr/share/applications/keepassgo.desktop"
|
||||||
|
install -Dm644 README.md \
|
||||||
|
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=KeePassGO
|
||||||
|
Comment=KeePass-compatible password manager
|
||||||
|
Exec=keepassgo
|
||||||
|
Icon=keepassgo
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;Security;
|
||||||
|
Keywords=keepass;password;vault;kdbx;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict:
|
||||||
|
data = None
|
||||||
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def request_no_content(method: str, url: str, token: str) -> None:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict:
|
||||||
|
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
|
||||||
|
tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}"
|
||||||
|
try:
|
||||||
|
return request_json("GET", tag_url, token)
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
if err.code != 404:
|
||||||
|
raise
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
return request_json("POST", f"{base}/releases", token, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None:
|
||||||
|
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
|
||||||
|
assets = release.get("assets", [])
|
||||||
|
for asset in assets:
|
||||||
|
if asset.get("name") == path.name:
|
||||||
|
request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token)
|
||||||
|
query = urllib.parse.urlencode({"name": path.name})
|
||||||
|
url = f"{base}/releases/{release['id']}/assets?{query}"
|
||||||
|
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=path.read_bytes(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": content_type,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--server", required=True)
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--token", required=True)
|
||||||
|
parser.add_argument("--tag", required=True)
|
||||||
|
parser.add_argument("artifacts", nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
paths = [pathlib.Path(p) for p in args.artifacts]
|
||||||
|
missing = [str(p) for p in paths if not p.is_file()]
|
||||||
|
if missing:
|
||||||
|
print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
release = get_or_create_release(args.server, args.repo, args.token, args.tag)
|
||||||
|
for path in paths:
|
||||||
|
upload_asset(args.server, args.repo, args.token, release, path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
|
|||||||
return m.persistMergedToCurrentSource(merged)
|
return m.persistMergedToCurrentSource(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
|
||||||
|
other, _, err := loadLocalSourceBytes(name, content, m.key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
merged, err := m.mergedWithPeer(other)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.persistMergedToCurrentSource(merged)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) SynchronizeToLocal(path string) error {
|
func (m *Manager) SynchronizeToLocal(path string) error {
|
||||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
|
|||||||
return model, config, nil
|
return model, config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
|
if len(content) == 0 {
|
||||||
|
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
|
||||||
|
}
|
||||||
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||||
|
if err != nil {
|
||||||
|
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
|
||||||
|
}
|
||||||
|
return model, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
model, config, err := loadLocalSource(path, key)
|
model, config, err := loadLocalSource(path, key)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
+140
-83
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
|
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
|
||||||
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
|
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
|||||||
updated := model
|
updated := model
|
||||||
updated.UpsertEntry(vault.Entry{
|
updated.UpsertEntry(vault.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
sess.Replace(updated)
|
sess.Replace(updated)
|
||||||
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"keepass", "Joe", "Internet"},
|
Path: []string{"keepass", "Crew", "Internet"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "entry-2",
|
ID: "entry-2",
|
||||||
Title: "Mail",
|
Title: "Mail",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://mail.julianfamily.org",
|
URL: "https://dispatch.crew.example.invalid",
|
||||||
Path: []string{"keepass", "Joe", "eMail"},
|
Path: []string{"keepass", "Crew", "eMail"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, key.Password); err != nil {
|
}, key.Password); err != nil {
|
||||||
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Current() error = %v", err)
|
t.Fatalf("Current() error = %v", err)
|
||||||
}
|
}
|
||||||
current.Entries[0].Path = []string{"Joe", "Internet"}
|
current.Entries[0].Path = []string{"Crew", "Internet"}
|
||||||
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
|
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
|
||||||
sess.Replace(current)
|
sess.Replace(current)
|
||||||
|
|
||||||
if err := sess.Save(); err != nil {
|
if err := sess.Save(); err != nil {
|
||||||
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
|||||||
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
|
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
|
||||||
}
|
}
|
||||||
rootGroups := db.Content.Root.Groups[0].Groups
|
rootGroups := db.Content.Root.Groups[0].Groups
|
||||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
|
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
|
||||||
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
|
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
|||||||
|
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
if len(got) != 1 || got[0].Password != "token-1" {
|
if len(got) != 1 || got[0].Password != "token-1" {
|
||||||
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
|
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
current.UpsertEntry(vault.Entry{
|
current.UpsertEntry(vault.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
})
|
})
|
||||||
sess.Replace(current)
|
sess.Replace(current)
|
||||||
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
current.UpsertEntry(vault.Entry{
|
current.UpsertEntry(vault.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
sess.Replace(current)
|
sess.Replace(current)
|
||||||
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
|||||||
t.Fatalf("Current() error = %v", err)
|
t.Fatalf("Current() error = %v", err)
|
||||||
}
|
}
|
||||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
if len(got) != 1 || got[0].Title != "Git Server" {
|
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||||
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
|
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reopened Manager
|
var reopened Manager
|
||||||
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
|||||||
{
|
{
|
||||||
UUID: gokeepasslib.NewUUID(),
|
UUID: gokeepasslib.NewUUID(),
|
||||||
Values: []gokeepasslib.ValueData{
|
Values: []gokeepasslib.ValueData{
|
||||||
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
|
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
|
||||||
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
|
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
|
||||||
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
|
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
|
||||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
|
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
current.UpsertEntry(vault.Entry{
|
current.UpsertEntry(vault.Entry{
|
||||||
ID: current.Entries[0].ID,
|
ID: current.Entries[0].ID,
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: current.Entries[0].Path,
|
Path: current.Entries[0].Path,
|
||||||
})
|
})
|
||||||
sess.Replace(current)
|
sess.Replace(current)
|
||||||
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Attachments: map[string][]byte{
|
Attachments: map[string][]byte{
|
||||||
"token.txt": []byte("secret attachment contents"),
|
"token.txt": []byte("secret attachment contents"),
|
||||||
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
|||||||
History: []vault.Entry{
|
History: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1-history-1",
|
ID: "entry-1-history-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
|||||||
}
|
}
|
||||||
firstCurrent.UpsertEntry(vault.Entry{
|
firstCurrent.UpsertEntry(vault.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "remote-token-2",
|
Password: "remote-token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "updated remotely first",
|
Notes: "updated remotely first",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
|||||||
}
|
}
|
||||||
secondCurrent.UpsertEntry(vault.Entry{
|
secondCurrent.UpsertEntry(vault.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "local-token-2",
|
Password: "local-token-2",
|
||||||
URL: "https://git.julianfamily.org/settings/tokens",
|
URL: "https://vault.crew.example.invalid/security/badges",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
second.Replace(secondCurrent)
|
second.Replace(secondCurrent)
|
||||||
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
|||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
|
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
|
||||||
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
|
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
|
||||||
}
|
}
|
||||||
if len(got[0].History) == 0 {
|
if len(got[0].History) == 0 {
|
||||||
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-current",
|
ID: "entry-current",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-current",
|
Password: "token-current",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-other",
|
ID: "entry-other",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "token-other",
|
Password: "token-other",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
||||||
|
|
||||||
|
currentModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-current",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-current",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
otherModel := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-other",
|
||||||
|
Title: "Bellagio",
|
||||||
|
Username: "rustyryan",
|
||||||
|
Password: "token-other",
|
||||||
|
URL: "https://bellagio.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
writeKDBXTestFile(t, currentPath, currentModel, key)
|
||||||
|
var other bytes.Buffer
|
||||||
|
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(other) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.Open(currentPath, key); err != nil {
|
||||||
|
t.Fatalf("Open(current) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
|
||||||
|
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened Manager
|
||||||
|
if err := reopened.Open(currentPath, key); err != nil {
|
||||||
|
t.Fatalf("reopen Open(current) error = %v", err)
|
||||||
|
}
|
||||||
|
current, err := reopened.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopened Current() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -964,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-current",
|
ID: "entry-current",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-current",
|
Password: "token-current",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -976,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-other",
|
ID: "entry-other",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "token-other",
|
Password: "token-other",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1021,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-current",
|
ID: "entry-current",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-current",
|
Password: "token-current",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1033,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-remote",
|
ID: "entry-remote",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "token-remote",
|
Password: "token-remote",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -471,7 +471,7 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
|
|||||||
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
|
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
|
||||||
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
|
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
|
||||||
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
|
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
|
||||||
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}">
|
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
|
||||||
{{.AppSnip}}
|
{{.AppSnip}}
|
||||||
<activity android:name="org.gioui.GioActivity"
|
<activity android:name="org.gioui.GioActivity"
|
||||||
android:label="{{.AppName}}"
|
android:label="{{.AppName}}"
|
||||||
|
|||||||
@@ -369,6 +369,57 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) apiPolicyGroupPathSummary() string {
|
||||||
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
|
if len(path) == 0 {
|
||||||
|
return "No group selected"
|
||||||
|
}
|
||||||
|
return strings.Join(path, " / ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) apiPolicyEntrySummary() string {
|
||||||
|
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
||||||
|
if id == "" {
|
||||||
|
return "No entry selected"
|
||||||
|
}
|
||||||
|
if item, ok := u.selectedEntry(); ok && item.ID == id {
|
||||||
|
if strings.TrimSpace(item.Title) != "" {
|
||||||
|
return item.Title + " (" + id + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) useCurrentGroupForPolicyAction() error {
|
||||||
|
u.syncCurrentPath()
|
||||||
|
path := u.displayPath()
|
||||||
|
if len(path) == 0 {
|
||||||
|
return fmt.Errorf("navigate to a group first")
|
||||||
|
}
|
||||||
|
u.apiPolicyGroupScope = true
|
||||||
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.apiPolicyPath.SetText(strings.Join(path, " / "))
|
||||||
|
u.apiPolicyEntryID.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) useSelectedEntryForPolicyAction() error {
|
||||||
|
item, ok := u.selectedEntry()
|
||||||
|
if !ok || strings.TrimSpace(item.ID) == "" {
|
||||||
|
return fmt.Errorf("select an entry first")
|
||||||
|
}
|
||||||
|
u.apiPolicyGroupScope = false
|
||||||
|
u.apiPolicyGroupScopeW.Value = false
|
||||||
|
u.apiPolicyEntryID.SetText(item.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearAPIPolicyTargetAction() error {
|
||||||
|
u.apiPolicyPath.SetText("")
|
||||||
|
u.apiPolicyEntryID.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1002,9 +1053,44 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
if u.apiPolicyGroupScopeW.Value {
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)),
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.useCurrentGroupForPolicy, "Use Current Group")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.useSelectedEntryForPolicy, "Use Selected Entry")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
||||||
|
|||||||
+291
-135
@@ -20,6 +20,9 @@ import (
|
|||||||
|
|
||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
|
showRemoteChooser := u.showRemoteConnectionChooser()
|
||||||
|
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
||||||
@@ -59,173 +62,172 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.lifecycleMode == "remote" {
|
if u.lifecycleMode == "remote" {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" {
|
if !showRemoteChooser {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
record := u.currentRemoteRecord()
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(lastGroup) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if busy {
|
if !showRemoteChooser {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return u.recentRemoteList(gtx)
|
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if showRemoteChooser && !busy {
|
||||||
|
return u.recentRemoteList(gtx)
|
||||||
|
}
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
||||||
box.Color = accentColor
|
box.Color = accentColor
|
||||||
return box.Layout(gtx)
|
return box.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showRemoteChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if busy {
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return u.recentVaultList(gtx)
|
return u.recentVaultList(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
selectedPath := strings.TrimSpace(u.vaultPath.Text())
|
if !showLocalChooser {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
switch {
|
switch {
|
||||||
case busy:
|
case busy:
|
||||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||||
case selectedPath == "":
|
case selectedLocalPath == "":
|
||||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||||
default:
|
default:
|
||||||
lastGroup := u.recentVaultGroup(selectedPath)
|
return layout.Dimensions{}
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
|
|
||||||
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(16), friendlyRecentVaultLabel(selectedPath))
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
dir := compactPathDirectorySummary(selectedPath)
|
|
||||||
if dir == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), dir)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(lastGroup) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -296,10 +298,26 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.lifecycleMode == "remote" {
|
if u.lifecycleMode == "remote" {
|
||||||
label := u.remoteOpenButtonLabel()
|
label := u.remoteOpenButtonLabel()
|
||||||
if busy {
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
return passiveTonedButton(gtx, u.theme, label)
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
}
|
if busy {
|
||||||
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
return passiveTonedButton(gtx, u.theme, label)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.hasSelectedRemoteTarget() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.hasSelectedRemoteTarget() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return u.selectedRemoteConnectionCard(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -325,11 +343,118 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || selectedLocalPath == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || selectedLocalPath == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||||
|
record := u.currentRemoteRecord()
|
||||||
|
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
||||||
|
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
|
Password: u.remotePassword.Text(),
|
||||||
|
}))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(lastGroup) == 0 {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
||||||
|
lastGroup := u.recentVaultGroup(path)
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
|
||||||
|
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(16), friendlyRecentVaultLabel(path))
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
dir := compactPathDirectorySummary(path)
|
||||||
|
if dir == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), dir)
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(lastGroup) == 0 {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
||||||
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
||||||
}
|
}
|
||||||
@@ -909,7 +1034,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
icon := u.expandLessIcon
|
icon := u.expandLessIcon
|
||||||
if u.groupControlsHidden {
|
if u.groupControlsHidden {
|
||||||
icon = u.expandMoreIcon
|
icon = u.chevronRightIcon
|
||||||
}
|
}
|
||||||
if icon == nil {
|
if icon == nil {
|
||||||
lbl := material.Label(u.theme, unit.Sp(16), ">")
|
lbl := material.Label(u.theme, unit.Sp(16), ">")
|
||||||
@@ -1212,12 +1337,14 @@ func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.E
|
|||||||
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
|
||||||
targetLabel := "Locked vault"
|
targetLabel := "Locked vault"
|
||||||
targetValue := "Unlock the active vault to continue."
|
targetValue := "Unlock the active vault to continue."
|
||||||
|
changeLabel := "Open Different Vault"
|
||||||
if u.state.Session != nil {
|
if u.state.Session != nil {
|
||||||
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
|
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
|
||||||
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
|
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
|
||||||
path := strings.TrimSpace(u.remotePath.Text())
|
path := strings.TrimSpace(u.remotePath.Text())
|
||||||
targetLabel = "Remote vault"
|
targetLabel = "Remote vault"
|
||||||
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
|
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
|
||||||
|
changeLabel = "Open Different Connection"
|
||||||
if strings.TrimSpace(targetValue) == "" {
|
if strings.TrimSpace(targetValue) == "" {
|
||||||
targetValue = "Remote WebDAV vault"
|
targetValue = "Remote WebDAV vault"
|
||||||
}
|
}
|
||||||
@@ -1233,25 +1360,42 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
targetCard := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(targetLabel))
|
||||||
|
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.Body1(u.theme, targetValue)
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !u.shouldUseLockedSinglePane() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if targetLabel == "Remote vault" {
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, changeLabel)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.clearVaultSelection, changeLabel)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
if u.mode == "desktop" {
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.Dimensions{}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
}
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
return targetCard(gtx)
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(targetLabel))
|
|
||||||
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.Body1(u.theme, targetValue)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -1263,6 +1407,18 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
|
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.mode != "desktop" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.mode != "desktop" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return targetCard(gtx)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-28
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "old-token",
|
Password: "old-token",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Original note",
|
Notes: "Original note",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model.UpsertEntry(Entry{
|
model.UpsertEntry(Entry{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "new-token",
|
Password: "new-token",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Updated note",
|
Notes: "Updated note",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
})
|
})
|
||||||
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "ha-codex",
|
ID: "surveillance-console",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.DeleteEntry("ha-codex"); err != nil {
|
if err := model.DeleteEntry("surveillance-console"); err != nil {
|
||||||
t.Fatalf("DeleteEntry() error = %v", err)
|
t.Fatalf("DeleteEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
|||||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
|
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
if model.RecycleBin[0].Title != "Surveillance Console" {
|
||||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
|
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
RecycleBin: []Entry{
|
RecycleBin: []Entry{
|
||||||
{
|
{
|
||||||
ID: "dynadot",
|
ID: "bellagio",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "token-3",
|
Password: "token-3",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.RestoreEntry("dynadot"); err != nil {
|
if err := model.RestoreEntry("bellagio"); err != nil {
|
||||||
t.Fatalf("RestoreEntry() error = %v", err)
|
t.Fatalf("RestoreEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
|||||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Title != "Dynadot" {
|
if got[0].Title != "Bellagio" {
|
||||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
|
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(model.RecycleBin) != 0 {
|
if len(model.RecycleBin) != 0 {
|
||||||
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "new-token",
|
Password: "new-token",
|
||||||
Notes: "Current note",
|
Notes: "Current note",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
History: []Entry{
|
History: []Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server-history-1",
|
ID: "vault-console-history-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "old-token",
|
Password: "old-token",
|
||||||
Notes: "Previous note",
|
Notes: "Previous note",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
|
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
|
||||||
t.Fatalf("RestoreEntryVersion() error = %v", err)
|
t.Fatalf("RestoreEntryVersion() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-46
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root",
|
mustGroup("Root",
|
||||||
mustGroup("Internet",
|
mustGroup("Internet",
|
||||||
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
|
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||||
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
|
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
||||||
),
|
),
|
||||||
mustGroup("Home Assistant",
|
mustGroup("Home Assistant",
|
||||||
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
|
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
|
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
|
||||||
t.Fatalf("unexpected first entry: %#v", got[0])
|
t.Fatalf("unexpected first entry: %#v", got[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
|
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
|
||||||
t.Fatalf("unexpected second entry: %#v", got[1])
|
t.Fatalf("unexpected second entry: %#v", got[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
|
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
||||||
entry.Tags = "automation; home"
|
entry.Tags = "automation; home"
|
||||||
entry.Values = append(entry.Values,
|
entry.Values = append(entry.Values,
|
||||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||||
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||||
Tags: []string{"git", "infra"},
|
Tags: []string{"git", "infra"},
|
||||||
Fields: map[string]string{
|
Fields: map[string]string{
|
||||||
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "entry-2",
|
ID: "entry-2",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||||
Tags: []string{"automation", "home"},
|
Tags: []string{"automation", "home"},
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBX() error = %v", err)
|
t.Fatalf("LoadKDBX() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.Search("git")
|
got := loaded.Search("vault")
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
|
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
|||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "new-token",
|
Password: "new-token",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
History: []Entry{
|
History: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1-old",
|
ID: "entry-1-old",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "old-token",
|
Password: "old-token",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Notes: "Original version",
|
Notes: "Original version",
|
||||||
},
|
},
|
||||||
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
RecycleBin: []Entry{
|
RecycleBin: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
|
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
|
||||||
}
|
}
|
||||||
|
|
||||||
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
if loaded.RecycleBin[0].Title != "Surveillance Console" {
|
||||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
|
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||||
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.Search("git")
|
got := model.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
|
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
|
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.Search("git")
|
got := loaded.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||||
t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
|
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "ha-codex",
|
ID: "surveillance-console",
|
||||||
Title: "Home Assistant (Codex)",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://lights.julianfamily.org",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
|||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server",
|
ID: "vault-console",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Attachments: map[string][]byte{
|
Attachments: map[string][]byte{
|
||||||
"token.txt": []byte("secret attachment contents"),
|
"token.txt": []byte("secret attachment contents"),
|
||||||
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Current credential",
|
Notes: "Current credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Attachments: map[string][]byte{
|
Attachments: map[string][]byte{
|
||||||
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
History: []Entry{
|
History: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1-history-1",
|
ID: "entry-1-history-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Original credential",
|
Notes: "Original credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
|
|||||||
+49
-49
@@ -9,9 +9,9 @@ import (
|
|||||||
func testModel() Model {
|
func testModel() Model {
|
||||||
return Model{
|
return Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
|
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
|
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ func testModel() Model {
|
|||||||
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
got := model.ChildGroups([]string{"Joe"})
|
got := model.ChildGroups([]string{"Crew"})
|
||||||
want := []string{"Home Assistant", "Internet"}
|
want := []string{"Home Assistant", "Internet"}
|
||||||
|
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
@@ -31,12 +31,12 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
|||||||
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
|
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
|
||||||
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
|
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,29 +45,29 @@ func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
model := testModel()
|
model := testModel()
|
||||||
got := model.EntriesUnderPath([]string{"Joe"})
|
got := model.EntriesUnderPath([]string{"Crew"})
|
||||||
if len(got) != 3 {
|
if len(got) != 3 {
|
||||||
t.Fatalf("len(EntriesUnderPath(Joe)) = %d, want 3", len(got))
|
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
|
||||||
}
|
}
|
||||||
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" || got[2].Title != "Home Assistant (Codex)" {
|
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
|
||||||
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
|
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
got := model.Search("git")
|
got := model.Search("vault")
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(Search()) = %d, want 1", len(got))
|
t.Fatalf("len(Search()) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Entry.Title != "Git Server" {
|
if got[0].Entry.Title != "Vault Console" {
|
||||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
|
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Path != "Joe / Internet" {
|
if got[0].Path != "Crew / Internet" {
|
||||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
|
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
|||||||
|
|
||||||
entry, err := model.InstantiateTemplate("tpl-1", Entry{
|
entry, err := model.InstantiateTemplate("tpl-1", Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Dynadot",
|
Title: "Bellagio",
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "hunter2",
|
Password: "hunter2",
|
||||||
URL: "https://www.dynadot.com",
|
URL: "https://bellagio.example.invalid",
|
||||||
Path: []string{"Joe", "Internet"},
|
Path: []string{"Crew", "Internet"},
|
||||||
Tags: []string{"dns"},
|
Tags: []string{"dns"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -134,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
|||||||
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
|
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Title != "Dynadot" {
|
if entry.Title != "Bellagio" {
|
||||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
|
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
|
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
|
||||||
t.Fatalf("entry credentials = %#v, want override values", entry)
|
t.Fatalf("entry credentials = %#v, want override values", entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
|||||||
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
|
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||||
if len(got) != 1 || got[0].Title != "Dynadot" {
|
if len(got) != 1 || got[0].Title != "Bellagio" {
|
||||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
|
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
|
|||||||
|
|
||||||
model := Model{
|
model := Model{
|
||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
Templates: []Entry{
|
Templates: []Entry{
|
||||||
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
||||||
@@ -222,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
|||||||
Entries: []Entry{
|
Entries: []Entry{
|
||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Git Server",
|
Title: "Vault Console",
|
||||||
Username: "joejulian",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
@@ -238,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
|||||||
if duplicate.ID != "entry-2" {
|
if duplicate.ID != "entry-2" {
|
||||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
|
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
|
||||||
}
|
}
|
||||||
if duplicate.Title != "Git Server (Copy)" {
|
if duplicate.Title != "Vault Console (Copy)" {
|
||||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
|
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
|
||||||
}
|
}
|
||||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
@@ -250,9 +250,9 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
|||||||
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||||
|
|
||||||
got := model.ChildGroups([]string{"Joe"})
|
got := model.ChildGroups([]string{"Crew"})
|
||||||
want := []string{"Finance", "Home Assistant", "Internet"}
|
want := []string{"Finance", "Home Assistant", "Internet"}
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||||
@@ -263,32 +263,32 @@ func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
model := testModel()
|
model := testModel()
|
||||||
model.CreateGroup([]string{"Joe"}, "Infrastructure / Prod")
|
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
|
||||||
|
|
||||||
got := model.ChildGroups([]string{"Joe"})
|
got := model.ChildGroups([]string{"Crew"})
|
||||||
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
||||||
t.Fatalf("ChildGroups(Joe) = %v, want [Home Assistant Infrastructure Internet]", got)
|
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
|
||||||
}
|
}
|
||||||
got = model.ChildGroups([]string{"Joe", "Infrastructure"})
|
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
|
||||||
if !slices.Equal(got, []string{"Prod"}) {
|
if !slices.Equal(got, []string{"Prod"}) {
|
||||||
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got)
|
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
|
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
|
||||||
t.Fatalf("RenameGroup() error = %v", err)
|
t.Fatalf("RenameGroup() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Joe", "Infra"})
|
got := model.EntriesInPath([]string{"Crew", "Infra"})
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
|
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
|
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||||
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
|
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +309,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
model := testModel()
|
model := testModel()
|
||||||
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure")
|
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
|
||||||
if err := model.MoveGroup([]string{"Joe", "Internet"}, []string{"Tricia"}); err != nil {
|
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
|
||||||
t.Fatalf("MoveGroup() error = %v", err)
|
t.Fatalf("MoveGroup() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,8 +318,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
|||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
|
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
|
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||||
t.Fatal("EntriesInPath(Joe/Internet) should be empty after move")
|
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
|
||||||
}
|
}
|
||||||
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
|
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
|
||||||
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
|
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
|
||||||
@@ -330,12 +330,12 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
|||||||
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
|
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||||
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
|
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
|
||||||
t.Fatalf("DeleteGroup() error = %v", err)
|
t.Fatalf("DeleteGroup() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.ChildGroups([]string{"Joe"})
|
got := model.ChildGroups([]string{"Crew"})
|
||||||
want := []string{"Home Assistant", "Internet"}
|
want := []string{"Home Assistant", "Internet"}
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
|||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
t.Fatalf("method = %s, want GET", r.Method)
|
t.Fatalf("method = %s, want GET", r.Method)
|
||||||
}
|
}
|
||||||
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
|
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
|
||||||
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
|
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
|
||||||
}
|
}
|
||||||
w.Header().Set("ETag", `"etag-1"`)
|
w.Header().Set("ETag", `"etag-1"`)
|
||||||
_, _ = io.WriteString(w, "vault-bytes")
|
_, _ = io.WriteString(w, "vault-bytes")
|
||||||
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
|||||||
client := Client{
|
client := Client{
|
||||||
HTTPClient: server.Client(),
|
HTTPClient: server.Client(),
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "secret",
|
Password: "secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
|
|||||||
client := Client{
|
client := Client{
|
||||||
HTTPClient: server.Client(),
|
HTTPClient: server.Client(),
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "secret",
|
Password: "secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
|
|||||||
client := Client{
|
client := Client{
|
||||||
HTTPClient: server.Client(),
|
HTTPClient: server.Client(),
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
Username: "jjulian",
|
Username: "rustyryan",
|
||||||
Password: "secret",
|
Password: "secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user