Compare commits
168 Commits
ed2118223f
..
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.
|
||||||
@@ -23,10 +24,26 @@ Installed machine prerequisites expected by this repo:
|
|||||||
- `android-sdk-build-tools`
|
- `android-sdk-build-tools`
|
||||||
- `android-platform-35`
|
- `android-platform-35`
|
||||||
- `android-sdk-platform-tools`
|
- `android-sdk-platform-tools`
|
||||||
- JDK 17 or newer
|
- a working JDK install
|
||||||
|
|
||||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go tool gogio -target android ...
|
go tool gogio -target android ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Android build uses the branded icon asset at:
|
||||||
|
|
||||||
|
- `assets/keepassgo-icon.png`
|
||||||
|
|
||||||
|
Note:
|
||||||
|
|
||||||
|
- Gio's Android doc currently references Java 1.8, but the Android build-tools
|
||||||
|
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
|
||||||
|
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
|
||||||
|
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
|
||||||
|
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
|
||||||
|
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
|
||||||
|
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
|
||||||
|
pipeline. KeePassGO is pinned to the working Gio line until that regression is
|
||||||
|
understood upstream.
|
||||||
|
|||||||
@@ -5,12 +5,24 @@ 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:
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a JDK 17+ install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
|
||||||
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
|
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
|
||||||
@@ -24,8 +36,23 @@ apk:
|
|||||||
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) \
|
||||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||||
|
-icon assets/keepassgo-icon.png \
|
||||||
.
|
.
|
||||||
|
|
||||||
|
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||||
|
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
|
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
|
||||||
|
@mkdir -p android
|
||||||
|
@zsh -lc 'tmpdir=$$(mktemp -d); \
|
||||||
|
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
|
||||||
|
"$(JAVA_HOME)/bin/javac" \
|
||||||
|
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
|
||||||
|
-d "$$tmpdir" \
|
||||||
|
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||||
|
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||||
|
|||||||
@@ -41,20 +41,55 @@ 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`.
|
||||||
|
|
||||||
|
The repo now has automated tests for the packaging contract:
|
||||||
|
- default APK build arguments
|
||||||
|
- required Android SDK / NDK / JDK layout checks
|
||||||
|
|
||||||
|
Those are covered by normal test runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install gioui.org/cmd/gogio@latest
|
go get -tool gioui.org/cmd/gogio@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Package:
|
Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gogio -target android .
|
go tool gogio -target android -icon assets/keepassgo-icon.png .
|
||||||
```
|
```
|
||||||
|
|
||||||
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||||
|
|||||||
@@ -6,6 +6,102 @@ These segments are intended to be independently executable wherever possible.
|
|||||||
Each segment has its own local exit criteria.
|
Each segment has its own local exit criteria.
|
||||||
The product is not complete until the global exit criteria at the end of this file are also met.
|
The product is not complete until the global exit criteria at the end of this file are also met.
|
||||||
|
|
||||||
|
## UI Review Follow-Ups
|
||||||
|
|
||||||
|
These items came from a hands-on emulator and desktop walkthrough.
|
||||||
|
They should be treated as usability work, not just polish.
|
||||||
|
|
||||||
|
### Primary Workflow Changes
|
||||||
|
|
||||||
|
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||||
|
|
||||||
|
- Local open flow:
|
||||||
|
make the start screen primarily about opening a vault, not configuring one.
|
||||||
|
- Local open flow:
|
||||||
|
keep recent vault selection visually obvious and clearly tappable.
|
||||||
|
- Local open flow:
|
||||||
|
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
|
||||||
|
- Local open flow:
|
||||||
|
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
|
||||||
|
- Local open flow:
|
||||||
|
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
|
||||||
|
- Remote open flow:
|
||||||
|
break the remote form into clearer sections such as `Location` and `Authentication`.
|
||||||
|
- Remote open flow:
|
||||||
|
make recent remote connections easier to scan with a friendlier label than raw URL and path.
|
||||||
|
- Locked screen:
|
||||||
|
show clear vault identity and target summary so the user knows what is being unlocked.
|
||||||
|
- Entries screen:
|
||||||
|
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
|
||||||
|
- Entries screen:
|
||||||
|
make breadcrumbs compress more aggressively on phone.
|
||||||
|
- Entries screen:
|
||||||
|
improve entry-row hierarchy and selected-state contrast.
|
||||||
|
- Entries screen:
|
||||||
|
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
|
||||||
|
- Group navigation:
|
||||||
|
make the distinction between root, current group, and child groups more obvious.
|
||||||
|
- Group navigation:
|
||||||
|
separate navigation controls from group-management controls more clearly.
|
||||||
|
- Entry detail:
|
||||||
|
tighten field spacing and reduce unnecessary whitespace.
|
||||||
|
- Entry detail:
|
||||||
|
group password reveal and copy actions more clearly.
|
||||||
|
- Entry detail:
|
||||||
|
make attachments more visible and actionable.
|
||||||
|
- Entry edit:
|
||||||
|
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
|
||||||
|
- Entry edit:
|
||||||
|
make add/remove affordances for custom fields more visually obvious.
|
||||||
|
- Entry edit:
|
||||||
|
make generated-password draft state more explicit before save.
|
||||||
|
- Recycle bin:
|
||||||
|
make it visually distinct from normal entry browsing.
|
||||||
|
- API tokens:
|
||||||
|
give token list, token detail, and policy editing a clearer dedicated management surface.
|
||||||
|
- API tokens:
|
||||||
|
make policy rows easier to scan by separating effect, operation, and resource visually.
|
||||||
|
- API audit:
|
||||||
|
improve empty-state guidance and provide quick filtering by token, decision, and operation.
|
||||||
|
- Synchronize:
|
||||||
|
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
|
||||||
|
- Synchronize:
|
||||||
|
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
|
||||||
|
- Phone layout:
|
||||||
|
continue reducing header and control density so content appears sooner.
|
||||||
|
- Mobile reliability:
|
||||||
|
fix Android local-open ANR behavior before deeper mobile polish.
|
||||||
|
- Autofill UX:
|
||||||
|
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
|
||||||
|
|
||||||
|
### Settings Gear Candidates
|
||||||
|
|
||||||
|
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
|
||||||
|
|
||||||
|
- Vault security:
|
||||||
|
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
|
||||||
|
- Vault security:
|
||||||
|
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
|
||||||
|
- Remote preferences:
|
||||||
|
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
|
||||||
|
- UI preferences:
|
||||||
|
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
|
||||||
|
- Autofill behavior:
|
||||||
|
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
|
||||||
|
- Sync defaults:
|
||||||
|
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
|
||||||
|
- Notification preferences:
|
||||||
|
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
|
||||||
|
- Accessibility preferences:
|
||||||
|
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
|
||||||
|
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
|
||||||
|
- Phone and desktop layouts both present a clear information hierarchy.
|
||||||
|
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||||
|
|
||||||
## API Token And gRPC Authorization Parallel Segments
|
## API Token And gRPC Authorization Parallel Segments
|
||||||
|
|
||||||
These segments define the work for programmatic access control over gRPC.
|
These segments define the work for programmatic access control over gRPC.
|
||||||
@@ -449,6 +545,21 @@ Exit criteria:
|
|||||||
- Focus and accessibility states are visible and intentional.
|
- Focus and accessibility states are visible and intentional.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
|
### Segment 21: Accessibility Fill Generalization
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Extend Android accessibility-based fill beyond the current Chrome demo path.
|
||||||
|
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
|
||||||
|
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
|
||||||
|
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
|
||||||
|
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
|
||||||
|
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
|
||||||
|
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 17: UI Completion And Error Surfaces
|
### Segment 17: UI Completion And Error Surfaces
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -533,48 +644,8 @@ Do not treat the product as complete until all of the following are true:
|
|||||||
|
|
||||||
## Remaining Gaps Against AGENTS.md
|
## Remaining Gaps Against AGENTS.md
|
||||||
|
|
||||||
This section tracks requirements from `AGENTS.md` that are not fully satisfied by the current landed code, even if the segment work and tests are green.
|
None currently identified.
|
||||||
|
|
||||||
### 1. KDBX Security Settings Are Only Preserved, Not Fully Product-Configurable
|
The last explicitly tracked gaps are now closed:
|
||||||
|
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
||||||
Evidence:
|
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
||||||
- `docs/kdbx-compatibility.md` states that KeePassGO preserves the original opened vault's cipher and KDF selection during save.
|
|
||||||
- The same document also states:
|
|
||||||
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly.
|
|
||||||
- New vault creation still uses library default KDBX header settings.
|
|
||||||
|
|
||||||
Why this is still a gap:
|
|
||||||
- `AGENTS.md` requires support for the major KeePass-style encryption and KDF configuration choices represented in KDBX databases.
|
|
||||||
- Preserving existing settings is good, but it is weaker than allowing the product user to choose those settings for new vaults or change them explicitly for existing vaults.
|
|
||||||
|
|
||||||
Remaining work:
|
|
||||||
- Expose major KDBX cipher/KDF choices in the product UI for vault creation.
|
|
||||||
- Expose supported security-setting changes for an existing unlocked vault.
|
|
||||||
- Add behavior tests covering explicit user selection of supported cipher/KDF options.
|
|
||||||
- Update `docs/kdbx-compatibility.md` once those product-facing controls exist.
|
|
||||||
|
|
||||||
Exit criteria for this gap:
|
|
||||||
- A user can create a vault with a selected supported cipher/KDF combination through the product.
|
|
||||||
- A user can change supported cipher/KDF settings for an existing vault through the product, where the underlying library supports it.
|
|
||||||
- Tests cover those choices end to end.
|
|
||||||
|
|
||||||
### 2. Accessibility Requirement Needs Explicit Screen-Reader Review
|
|
||||||
|
|
||||||
Evidence:
|
|
||||||
- Keyboard-first behavior and focus handling exist in the landed code.
|
|
||||||
- Accessibility labels exist in `ui_accessibility.go`.
|
|
||||||
- The repository does not currently contain a documented review of what screen-reader-conscious behavior Gio can and cannot provide on the supported desktop targets.
|
|
||||||
|
|
||||||
Why this is still a gap:
|
|
||||||
- `AGENTS.md` explicitly calls for screen-reader-conscious design, not only keyboard shortcuts and focus states.
|
|
||||||
- The current code suggests intent, but the repo does not yet document a concrete accessibility support boundary or validation result.
|
|
||||||
|
|
||||||
Remaining work:
|
|
||||||
- Audit the current Gio accessibility surface on Linux and Windows for the controls used by KeePassGO.
|
|
||||||
- Document what is currently exposed, what is intentionally labeled, and what remains limited by the toolkit.
|
|
||||||
- Add targeted tests for any label/focus mapping that can be verified in-repo.
|
|
||||||
|
|
||||||
Exit criteria for this gap:
|
|
||||||
- The repo includes a documented accessibility review for current desktop targets.
|
|
||||||
- Screen-reader-conscious behavior is explicitly described rather than implied.
|
|
||||||
- Any in-repo verifiable accessibility mappings have tests.
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<service
|
||||||
|
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="KeePassGO Autofill"
|
||||||
|
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.autofill.AutofillService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.autofill"
|
||||||
|
android:resource="@xml/keepassgo_autofill_service" />
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="KeePassGO Accessibility Fill"
|
||||||
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accessibilityservice"
|
||||||
|
android:resource="@xml/keepassgo_accessibility_service" />
|
||||||
|
</service>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
|
||||||
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
|
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
|
||||||
|
android:canRetrieveWindowContent="true"
|
||||||
|
android:notificationTimeout="100"
|
||||||
|
android:settingsActivity="" />
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.JsonReader;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
final class AutofillCacheStore {
|
||||||
|
private static final String TAG = "KeePassGOAutofill";
|
||||||
|
|
||||||
|
private AutofillCacheStore() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entry findBestMatch(Context context, String webDomain) {
|
||||||
|
File cacheFile = findCacheFile(context);
|
||||||
|
if (cacheFile == null) {
|
||||||
|
Log.i(TAG, "autofill cache file not found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Entry> entries;
|
||||||
|
try {
|
||||||
|
entries = readEntries(cacheFile);
|
||||||
|
} catch (IOException err) {
|
||||||
|
Log.e(TAG, "failed to read autofill cache", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
NormalizedTarget target = normalizeURL(webDomain);
|
||||||
|
if (target.host.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
if (entryMatchesHost(entry, target.host)) {
|
||||||
|
exactHost.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entryMatchesParentHost(entry, target.host)) {
|
||||||
|
parentHost.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Entry matched = chooseEntry(target, exactHost);
|
||||||
|
if (matched != null) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return chooseEntry(target, parentHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File findCacheFile(Context context) {
|
||||||
|
List<File> candidates = new ArrayList<>();
|
||||||
|
File filesDir = context.getFilesDir();
|
||||||
|
if (filesDir != null) {
|
||||||
|
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
|
||||||
|
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
|
||||||
|
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
|
||||||
|
}
|
||||||
|
File baseDir = context.getDataDir();
|
||||||
|
if (baseDir != null) {
|
||||||
|
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
|
||||||
|
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
|
||||||
|
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
|
||||||
|
}
|
||||||
|
for (File candidate : candidates) {
|
||||||
|
if (candidate.isFile()) {
|
||||||
|
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i(TAG, "no autofill cache file in " + candidates);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entry> readEntries(File cacheFile) throws IOException {
|
||||||
|
List<Entry> entries = new ArrayList<>();
|
||||||
|
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
|
||||||
|
reader.beginObject();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
String name = reader.nextName();
|
||||||
|
if ("entries".equals(name)) {
|
||||||
|
reader.beginArray();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
entries.add(readEntry(reader));
|
||||||
|
}
|
||||||
|
reader.endArray();
|
||||||
|
} else {
|
||||||
|
reader.skipValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry readEntry(JsonReader reader) throws IOException {
|
||||||
|
String title = "";
|
||||||
|
String username = "";
|
||||||
|
String password = "";
|
||||||
|
String host = "";
|
||||||
|
String url = "";
|
||||||
|
List<String> targets = new ArrayList<>();
|
||||||
|
reader.beginObject();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
String name = reader.nextName();
|
||||||
|
switch (name) {
|
||||||
|
case "title":
|
||||||
|
title = nextString(reader);
|
||||||
|
break;
|
||||||
|
case "username":
|
||||||
|
username = nextString(reader);
|
||||||
|
break;
|
||||||
|
case "password":
|
||||||
|
password = nextString(reader);
|
||||||
|
break;
|
||||||
|
case "url":
|
||||||
|
url = nextString(reader);
|
||||||
|
break;
|
||||||
|
case "host":
|
||||||
|
host = normalizeHost(nextString(reader));
|
||||||
|
break;
|
||||||
|
case "targets":
|
||||||
|
reader.beginArray();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
targets.add(nextString(reader));
|
||||||
|
}
|
||||||
|
reader.endArray();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reader.skipValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
return new Entry(title, username, password, host, url, targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nextString(JsonReader reader) throws IOException {
|
||||||
|
if (reader.peek() == android.util.JsonToken.NULL) {
|
||||||
|
reader.nextNull();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return reader.nextString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHost(String raw) {
|
||||||
|
return normalizeURL(raw).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NormalizedTarget normalizeURL(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return new NormalizedTarget("", "", "");
|
||||||
|
}
|
||||||
|
String value = raw.trim().toLowerCase(Locale.US);
|
||||||
|
if (value.startsWith("http://")) {
|
||||||
|
value = value.substring("http://".length());
|
||||||
|
} else if (value.startsWith("https://")) {
|
||||||
|
value = value.substring("https://".length());
|
||||||
|
}
|
||||||
|
int slash = value.indexOf('/');
|
||||||
|
if (slash >= 0) {
|
||||||
|
value = value.substring(0, slash);
|
||||||
|
}
|
||||||
|
int colon = value.indexOf(':');
|
||||||
|
if (colon >= 0) {
|
||||||
|
value = value.substring(0, colon);
|
||||||
|
}
|
||||||
|
String host = value;
|
||||||
|
String path = "/";
|
||||||
|
int schemeSep = raw.indexOf("://");
|
||||||
|
String original = raw.trim();
|
||||||
|
if (schemeSep < 0) {
|
||||||
|
original = "https://" + original;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.net.URI uri = java.net.URI.create(original);
|
||||||
|
if (uri.getHost() != null) {
|
||||||
|
host = uri.getHost().toLowerCase(Locale.US);
|
||||||
|
}
|
||||||
|
path = cleanPath(uri.getPath());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
return new NormalizedTarget(host, path, host + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cleanPath(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
String value = raw.trim();
|
||||||
|
while (value.endsWith("/") && value.length() > 1) {
|
||||||
|
value = value.substring(0, value.length() - 1);
|
||||||
|
}
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
value = "/" + value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entries.size() == 1) {
|
||||||
|
return entries.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Entry> exact = new ArrayList<>();
|
||||||
|
List<Entry> prefix = new ArrayList<>();
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
MatchQuality quality = bestTargetMatch(entry, target);
|
||||||
|
if (quality.exact) {
|
||||||
|
exact.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quality.prefixLength > bestPrefixLen) {
|
||||||
|
prefix.clear();
|
||||||
|
prefix.add(entry);
|
||||||
|
bestPrefixLen = quality.prefixLength;
|
||||||
|
} else if (quality.prefixLength == bestPrefixLen) {
|
||||||
|
prefix.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exact.size() == 1) {
|
||||||
|
return exact.get(0);
|
||||||
|
}
|
||||||
|
if (exact.size() > 1 || prefix.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix.size() == 1 ? prefix.get(0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (target.host.equals(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean entryMatchesParentHost(Entry entry, String host) {
|
||||||
|
for (NormalizedTarget target : entryTargets(entry)) {
|
||||||
|
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<NormalizedTarget> entryTargets(Entry entry) {
|
||||||
|
List<String> rawTargets = entry.targets;
|
||||||
|
if (rawTargets.isEmpty()) {
|
||||||
|
rawTargets = new ArrayList<>();
|
||||||
|
rawTargets.add(entry.url);
|
||||||
|
}
|
||||||
|
List<NormalizedTarget> targets = new ArrayList<>();
|
||||||
|
for (String rawTarget : rawTargets) {
|
||||||
|
NormalizedTarget target = normalizeURL(rawTarget);
|
||||||
|
if (!target.host.isEmpty()) {
|
||||||
|
targets.add(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
|
||||||
|
int bestPrefixLen = -1;
|
||||||
|
for (NormalizedTarget entryTarget : entryTargets(entry)) {
|
||||||
|
if (entryTarget.url.equals(target.url)) {
|
||||||
|
return new MatchQuality(true, 0);
|
||||||
|
}
|
||||||
|
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
|
||||||
|
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MatchQuality(false, bestPrefixLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Entry {
|
||||||
|
final String title;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String host;
|
||||||
|
final String url;
|
||||||
|
final List<String> targets;
|
||||||
|
|
||||||
|
Entry(String title, String username, String password, String host, String url, List<String> targets) {
|
||||||
|
this.title = title;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.host = host;
|
||||||
|
this.url = url;
|
||||||
|
this.targets = new ArrayList<>(targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MatchQuality {
|
||||||
|
final boolean exact;
|
||||||
|
final int prefixLength;
|
||||||
|
|
||||||
|
MatchQuality(boolean exact, int prefixLength) {
|
||||||
|
this.exact = exact;
|
||||||
|
this.prefixLength = prefixLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NormalizedTarget {
|
||||||
|
final String host;
|
||||||
|
final String path;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
NormalizedTarget(String host, String path, String url) {
|
||||||
|
this.host = host;
|
||||||
|
this.path = path;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityService;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.accessibility.AccessibilityEvent;
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class KeePassGOAccessibilityService extends AccessibilityService {
|
||||||
|
private static final String TAG = "KeePassGOA11y";
|
||||||
|
|
||||||
|
private String lastFilledSignature = "";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAccessibilityEvent(AccessibilityEvent event) {
|
||||||
|
try {
|
||||||
|
AccessibilityNodeInfo root = getRootInActiveWindow();
|
||||||
|
if (root == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CharSequence packageName = root.getPackageName();
|
||||||
|
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChromeForm form = inspectChrome(root);
|
||||||
|
if (form == null || form.passwordField == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
|
||||||
|
if (entry == null) {
|
||||||
|
Log.i(TAG, "no accessibility-fill match for " + form.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
|
||||||
|
if (signature.equals(lastFilledSignature)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean filled = false;
|
||||||
|
if (form.usernameField != null) {
|
||||||
|
filled |= setNodeText(form.usernameField, entry.username);
|
||||||
|
}
|
||||||
|
filled |= setNodeText(form.passwordField, entry.password);
|
||||||
|
if (filled) {
|
||||||
|
lastFilledSignature = signature;
|
||||||
|
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
|
||||||
|
}
|
||||||
|
} catch (Exception err) {
|
||||||
|
Log.e(TAG, "accessibility fill failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInterrupt() {
|
||||||
|
Log.i(TAG, "accessibility service interrupted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
|
||||||
|
List<AccessibilityNodeInfo> editables = new ArrayList<>();
|
||||||
|
ChromeForm form = new ChromeForm();
|
||||||
|
walk(root, editables, form);
|
||||||
|
if (form.url.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (form.passwordField == null) {
|
||||||
|
for (AccessibilityNodeInfo node : editables) {
|
||||||
|
if (isPasswordNode(node)) {
|
||||||
|
form.passwordField = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.usernameField == null && form.passwordField != null) {
|
||||||
|
for (AccessibilityNodeInfo node : editables) {
|
||||||
|
if (node.equals(form.passwordField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isUsernameNode(node)) {
|
||||||
|
form.usernameField = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.usernameField == null) {
|
||||||
|
for (AccessibilityNodeInfo node : editables) {
|
||||||
|
if (node.equals(form.passwordField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
form.usernameField = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void walk(
|
||||||
|
AccessibilityNodeInfo node,
|
||||||
|
List<AccessibilityNodeInfo> editables,
|
||||||
|
ChromeForm form
|
||||||
|
) {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CharSequence viewID = node.getViewIdResourceName();
|
||||||
|
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
|
||||||
|
CharSequence text = node.getText();
|
||||||
|
if (text != null) {
|
||||||
|
form.url = text.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.isEditable()) {
|
||||||
|
editables.add(node);
|
||||||
|
if (form.passwordField == null && isPasswordNode(node)) {
|
||||||
|
form.passwordField = node;
|
||||||
|
} else if (form.usernameField == null && isUsernameNode(node)) {
|
||||||
|
form.usernameField = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < node.getChildCount(); i++) {
|
||||||
|
AccessibilityNodeInfo child = node.getChild(i);
|
||||||
|
if (child != null) {
|
||||||
|
walk(child, editables, form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
|
||||||
|
if (node.isPassword()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return nodeMatches(node, "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
|
||||||
|
if (isPasswordNode(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nodeMatches(node, "username")
|
||||||
|
|| nodeMatches(node, "email")
|
||||||
|
|| nodeMatches(node, "login")
|
||||||
|
|| nodeMatches(node, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
|
||||||
|
String lowerTerm = term.toLowerCase();
|
||||||
|
return containsLower(node.getHintText(), lowerTerm)
|
||||||
|
|| containsLower(node.getText(), lowerTerm)
|
||||||
|
|| containsLower(node.getContentDescription(), lowerTerm)
|
||||||
|
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|
||||||
|
|| containsLower(node.getPaneTitle(), lowerTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsLower(CharSequence value, String term) {
|
||||||
|
return value != null && value.toString().toLowerCase().contains(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
|
||||||
|
if (node == null || !node.isEditable() || value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Bundle args = new Bundle();
|
||||||
|
args.putCharSequence(
|
||||||
|
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nodeKey(AccessibilityNodeInfo node) {
|
||||||
|
CharSequence viewID = node.getViewIdResourceName();
|
||||||
|
if (viewID != null) {
|
||||||
|
return viewID.toString();
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
return String.valueOf(node.getUniqueId());
|
||||||
|
}
|
||||||
|
CharSequence hint = node.getHintText();
|
||||||
|
if (hint != null) {
|
||||||
|
return hint.toString();
|
||||||
|
}
|
||||||
|
return String.valueOf(node.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ChromeForm {
|
||||||
|
String url = "";
|
||||||
|
AccessibilityNodeInfo usernameField;
|
||||||
|
AccessibilityNodeInfo passwordField;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.assist.AssistStructure;
|
||||||
|
import android.os.CancellationSignal;
|
||||||
|
import android.service.autofill.AutofillService;
|
||||||
|
import android.service.autofill.Dataset;
|
||||||
|
import android.service.autofill.FillCallback;
|
||||||
|
import android.service.autofill.FillContext;
|
||||||
|
import android.service.autofill.FillRequest;
|
||||||
|
import android.service.autofill.FillResponse;
|
||||||
|
import android.service.autofill.SaveCallback;
|
||||||
|
import android.service.autofill.SaveRequest;
|
||||||
|
import android.text.InputType;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.autofill.AutofillId;
|
||||||
|
import android.view.autofill.AutofillValue;
|
||||||
|
import android.widget.RemoteViews;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class KeePassGOAutofillService extends AutofillService {
|
||||||
|
private static final String TAG = "KeePassGOAutofill";
|
||||||
|
private static final String APP_SCHEME = "androidapp://";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnected() {
|
||||||
|
super.onConnected();
|
||||||
|
Log.i(TAG, "service connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected() {
|
||||||
|
Log.i(TAG, "service disconnected");
|
||||||
|
super.onDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFillRequest(
|
||||||
|
FillRequest request,
|
||||||
|
CancellationSignal cancellationSignal,
|
||||||
|
FillCallback callback
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
Log.i(TAG, "fill request flags=" + request.getFlags());
|
||||||
|
List<FillContext> contexts = request.getFillContexts();
|
||||||
|
if (contexts.isEmpty()) {
|
||||||
|
Log.i(TAG, "fill request had no contexts");
|
||||||
|
callback.onSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
|
||||||
|
ParsedFields fields = new ParsedFields();
|
||||||
|
ParsedTarget target = parseWindow(structure, fields);
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"parsed target=" + target.matchTarget
|
||||||
|
+ " package=" + target.packageName
|
||||||
|
+ " usernameId=" + fields.usernameId
|
||||||
|
+ " passwordId=" + fields.passwordId
|
||||||
|
);
|
||||||
|
if (fields.passwordId == null) {
|
||||||
|
Log.i(TAG, "no password field found");
|
||||||
|
callback.onSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
|
||||||
|
if (entry == null) {
|
||||||
|
Log.i(TAG, "no autofill cache match");
|
||||||
|
callback.onSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
||||||
|
|
||||||
|
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
||||||
|
presentation.setTextViewText(
|
||||||
|
android.R.id.text1,
|
||||||
|
entry.title + " (" + entry.username + ")"
|
||||||
|
);
|
||||||
|
|
||||||
|
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
||||||
|
if (fields.usernameId != null) {
|
||||||
|
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
||||||
|
}
|
||||||
|
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
||||||
|
|
||||||
|
FillResponse response = new FillResponse.Builder()
|
||||||
|
.addDataset(dataset.build())
|
||||||
|
.build();
|
||||||
|
Log.i(TAG, "returning dataset");
|
||||||
|
callback.onSuccess(response);
|
||||||
|
} catch (Exception err) {
|
||||||
|
Log.e(TAG, "fill request failed", err);
|
||||||
|
callback.onFailure(err.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
|
||||||
|
Log.i(TAG, "save request");
|
||||||
|
callback.onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||||
|
String domain = "";
|
||||||
|
final int windowCount = structure.getWindowNodeCount();
|
||||||
|
for (int i = 0; i < windowCount; i++) {
|
||||||
|
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
|
||||||
|
String next = parseNode(root, fields);
|
||||||
|
if (!next.isEmpty()) {
|
||||||
|
domain = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String packageName = "";
|
||||||
|
if (structure.getActivityComponent() != null) {
|
||||||
|
packageName = structure.getActivityComponent().getPackageName();
|
||||||
|
}
|
||||||
|
if (!domain.isEmpty()) {
|
||||||
|
return new ParsedTarget(domain, packageName);
|
||||||
|
}
|
||||||
|
if (packageName != null && !packageName.isEmpty()) {
|
||||||
|
return new ParsedTarget(APP_SCHEME + packageName, packageName);
|
||||||
|
}
|
||||||
|
return new ParsedTarget("", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
|
||||||
|
String domain = "";
|
||||||
|
if (node.getWebDomain() != null) {
|
||||||
|
domain = node.getWebDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
AutofillId id = node.getAutofillId();
|
||||||
|
if (id != null) {
|
||||||
|
if (fields.passwordId == null && isPasswordNode(node)) {
|
||||||
|
fields.passwordId = id;
|
||||||
|
} else if (fields.usernameId == null && isUsernameNode(node)) {
|
||||||
|
fields.usernameId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < node.getChildCount(); i++) {
|
||||||
|
String childDomain = parseNode(node.getChildAt(i), fields);
|
||||||
|
if (!childDomain.isEmpty()) {
|
||||||
|
domain = childDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
|
||||||
|
int inputType = node.getInputType();
|
||||||
|
int variation = inputType & InputType.TYPE_MASK_VARIATION;
|
||||||
|
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||||
|
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return nodeMatches(node, "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
|
||||||
|
int autofillType = node.getAutofillType();
|
||||||
|
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isPasswordNode(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nodeMatches(node, "username")
|
||||||
|
|| nodeMatches(node, "email")
|
||||||
|
|| nodeMatches(node, "login")
|
||||||
|
|| nodeMatches(node, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
|
||||||
|
String lowerTerm = term.toLowerCase();
|
||||||
|
String[] hints = node.getAutofillHints();
|
||||||
|
if (hints != null) {
|
||||||
|
for (String hint : hints) {
|
||||||
|
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (containsLower(node.getHint(), lowerTerm)
|
||||||
|
|| containsLower(node.getIdEntry(), lowerTerm)
|
||||||
|
|| containsLower(node.getText(), lowerTerm)
|
||||||
|
|| containsLower(node.getContentDescription(), lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsLower(CharSequence value, String term) {
|
||||||
|
return value != null && value.toString().toLowerCase().contains(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ParsedFields {
|
||||||
|
AutofillId usernameId;
|
||||||
|
AutofillId passwordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ParsedTarget {
|
||||||
|
final String matchTarget;
|
||||||
|
final String packageName;
|
||||||
|
|
||||||
|
ParsedTarget(String matchTarget, String packageName) {
|
||||||
|
this.matchTarget = matchTarget;
|
||||||
|
this.packageName = packageName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DirtyProvider func() bool
|
||||||
|
|
||||||
|
type Host struct {
|
||||||
|
server *Server
|
||||||
|
grpcServer *grpc.Server
|
||||||
|
listener net.Listener
|
||||||
|
lifecycle lifecycleBackend
|
||||||
|
dirty DirtyProvider
|
||||||
|
mu sync.Mutex
|
||||||
|
lastModel vault.Model
|
||||||
|
started bool
|
||||||
|
listenAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" || strings.EqualFold(addr, "off") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
||||||
|
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
|
host := &Host{
|
||||||
|
server: service,
|
||||||
|
grpcServer: server,
|
||||||
|
listener: listener,
|
||||||
|
lifecycle: lifecycle,
|
||||||
|
dirty: dirty,
|
||||||
|
listenAddr: listener.Addr().String(),
|
||||||
|
started: true,
|
||||||
|
}
|
||||||
|
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||||
|
_ = listener.Close()
|
||||||
|
server.Stop()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) Address() string {
|
||||||
|
if h == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.listenAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) Server() *Server {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) Stop() error {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if !h.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h.started = false
|
||||||
|
h.grpcServer.Stop()
|
||||||
|
return h.listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) SyncFromLifecycle() error {
|
||||||
|
if h == nil || h.lifecycle == nil || h.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
model, err := h.lifecycle.Current()
|
||||||
|
locked := false
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
h.lastModel = model
|
||||||
|
case errors.Is(err, session.ErrLocked):
|
||||||
|
locked = true
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty := false
|
||||||
|
if h.dirty != nil {
|
||||||
|
dirty = h.dirty()
|
||||||
|
}
|
||||||
|
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lifecycle := &session.Manager{}
|
||||||
|
if err := lifecycle.Create(vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartHost() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = host.Stop() }()
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return net.Dial("tcp", host.Address())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.NewClient() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
client := keepassgov1.NewVaultServiceClient(conn)
|
||||||
|
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = true, want false")
|
||||||
|
}
|
||||||
|
if !statusResp.Dirty {
|
||||||
|
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lifecycle.Lock(); err != nil {
|
||||||
|
t.Fatalf("Lock() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := host.SyncFromLifecycle(); err != nil {
|
||||||
|
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() after lock error = %v", err)
|
||||||
|
}
|
||||||
|
if !statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
@@ -31,6 +35,8 @@ type Server struct {
|
|||||||
lifecycle lifecycleBackend
|
lifecycle lifecycleBackend
|
||||||
profiles map[string]passwords.Profile
|
profiles map[string]passwords.Profile
|
||||||
clipboard clipboard.Writer
|
clipboard clipboard.Writer
|
||||||
|
approvals *apiapproval.Broker
|
||||||
|
audit *apiaudit.Log
|
||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleBackend interface {
|
type lifecycleBackend interface {
|
||||||
@@ -42,11 +48,18 @@ type lifecycleBackend interface {
|
|||||||
Unlock(vault.MasterKey) error
|
Unlock(vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type modelReplaceableLifecycle interface {
|
||||||
|
lifecycleBackend
|
||||||
|
Replace(vault.Model)
|
||||||
|
}
|
||||||
|
|
||||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
model: model,
|
model: model,
|
||||||
profiles: profiles,
|
profiles: profiles,
|
||||||
clipboard: clipboardWriter,
|
clipboard: clipboardWriter,
|
||||||
|
approvals: apiapproval.NewBroker(30 * time.Second),
|
||||||
|
audit: apiaudit.New(200),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +69,26 @@ func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Pro
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) ApprovalBroker() *apiapproval.Broker {
|
||||||
|
return s.approvals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AuditLog() *apiaudit.Log {
|
||||||
|
return s.audit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
|
return s.approvals.Resolve(id, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.model = model
|
||||||
|
s.locked = locked
|
||||||
|
s.dirty = dirty
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -190,23 +223,25 @@ func mapLifecycleError(operation string, err error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.RUnlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
model = visibleModel(model)
|
||||||
var entries []vault.Entry
|
var entries []vault.Entry
|
||||||
if strings.TrimSpace(req.GetQuery()) != "" {
|
if strings.TrimSpace(req.GetQuery()) != "" {
|
||||||
results := s.model.Search(req.GetQuery())
|
results := model.Search(req.GetQuery())
|
||||||
entries = make([]vault.Entry, 0, len(results))
|
entries = make([]vault.Entry, 0, len(results))
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
entries = append(entries, result.Entry)
|
entries = append(entries, result.Entry)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
entries = s.model.EntriesInPath(req.GetPath())
|
entries = model.EntriesInPath(req.GetPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := &keepassgov1.ListEntriesResponse{
|
resp := &keepassgov1.ListEntriesResponse{
|
||||||
@@ -219,23 +254,27 @@ func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequ
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.RUnlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &keepassgov1.ListGroupsResponse{
|
return &keepassgov1.ListGroupsResponse{
|
||||||
Names: s.model.ChildGroups(req.GetPath()),
|
Names: visibleModel(model).ChildGroups(req.GetPath()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
@@ -245,10 +284,13 @@ func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequ
|
|||||||
return &keepassgov1.CreateGroupResponse{}, nil
|
return &keepassgov1.CreateGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
@@ -264,10 +306,13 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
|
|||||||
return &keepassgov1.RenameGroupResponse{}, nil
|
return &keepassgov1.RenameGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
@@ -287,12 +332,15 @@ func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequ
|
|||||||
return &keepassgov1.DeleteGroupResponse{}, nil
|
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||||
if req.GetEntry() == nil {
|
if req.GetEntry() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := entryFromProto(req.GetEntry())
|
entry := entryFromProto(req.GetEntry())
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.locked {
|
if s.locked {
|
||||||
@@ -306,13 +354,19 @@ func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequ
|
|||||||
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
||||||
s.mu.Lock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.Unlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
if entry, err := findEntryByID(model, req.GetId()); err == nil {
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if err := s.model.DeleteEntry(req.GetId()); err != nil {
|
if err := s.model.DeleteEntry(req.GetId()); err != nil {
|
||||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
@@ -325,21 +379,27 @@ func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequ
|
|||||||
return &keepassgov1.DeleteEntryResponse{}, nil
|
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
||||||
s.mu.Lock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.Unlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
var restored vault.Entry
|
var restored vault.Entry
|
||||||
for _, entry := range s.model.RecycleBin {
|
for _, entry := range model.RecycleBin {
|
||||||
if entry.ID == req.GetId() {
|
if entry.ID == req.GetId() {
|
||||||
restored = entry
|
restored = entry
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if restored.ID != "" {
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if err := s.model.RestoreEntry(req.GetId()); err != nil {
|
if err := s.model.RestoreEntry(req.GetId()); err != nil {
|
||||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
@@ -352,18 +412,19 @@ func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRe
|
|||||||
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.RUnlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := findEntryByID(s.model, req.GetId())
|
entry, err := findEntryByID(model, req.GetId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
resp := &keepassgov1.ListEntryHistoryResponse{
|
resp := &keepassgov1.ListEntryHistoryResponse{
|
||||||
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
||||||
@@ -374,14 +435,21 @@ func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryH
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
||||||
s.mu.Lock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.Unlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
|
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
|
||||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
@@ -389,7 +457,7 @@ func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.Restore
|
|||||||
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
|
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := findEntryByID(s.model, req.GetId())
|
entry, err = findEntryByID(s.model, req.GetId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
@@ -477,18 +545,19 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
|
|||||||
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.RUnlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := findEntryByID(s.model, req.GetEntryId())
|
entry, err := findEntryByID(model, req.GetEntryId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
names := make([]string, 0, len(entry.Attachments))
|
names := make([]string, 0, len(entry.Attachments))
|
||||||
for name := range entry.Attachments {
|
for name := range entry.Attachments {
|
||||||
@@ -499,14 +568,21 @@ func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachm
|
|||||||
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
|
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
||||||
s.mu.Lock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.Unlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
@@ -522,18 +598,19 @@ func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAtta
|
|||||||
return &keepassgov1.UploadAttachmentResponse{}, nil
|
return &keepassgov1.UploadAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.RUnlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := findEntryByID(s.model, req.GetEntryId())
|
entry, err := findEntryByID(model, req.GetEntryId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
content, ok := entry.Attachments[req.GetName()]
|
content, ok := entry.Attachments[req.GetName()]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -545,14 +622,21 @@ func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.Download
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
||||||
s.mu.Lock()
|
model, locked := s.snapshotModel()
|
||||||
defer s.mu.Unlock()
|
if locked {
|
||||||
|
|
||||||
if s.locked {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetEntryId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
@@ -572,15 +656,18 @@ func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAtta
|
|||||||
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
||||||
s.mu.RLock()
|
model, locked := s.snapshotModel()
|
||||||
model := s.model
|
|
||||||
locked := s.locked
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
if locked {
|
if locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
service := clipboard.Service{Writer: s.clipboard}
|
service := clipboard.Service{Writer: s.clipboard}
|
||||||
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
|
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
|
||||||
@@ -597,10 +684,14 @@ func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFie
|
|||||||
return &keepassgov1.CopyEntryFieldResponse{}, nil
|
return &keepassgov1.CopyEntryFieldResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if _, err := s.authenticateRequest(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
@@ -665,27 +756,224 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
|||||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
|
out := model
|
||||||
|
out.Entries = nil
|
||||||
|
for _, entry := range model.Entries {
|
||||||
|
token, ok, err := apitokens.TokenFromEntry(entry)
|
||||||
|
if err == nil && ok && token.ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Entries = append(out.Entries, entry)
|
||||||
|
}
|
||||||
|
out.Groups = nil
|
||||||
|
for _, path := range model.Groups {
|
||||||
|
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Groups = append(out.Groups, path)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) snapshotModel() (vault.Model, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.model, s.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeNow = func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
|
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
|
||||||
|
}
|
||||||
|
values := md.Get("authorization")
|
||||||
|
if len(values) == 0 {
|
||||||
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
|
||||||
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
|
||||||
|
}
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(values[0], prefix) {
|
||||||
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
|
||||||
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
tokens, err := apitokens.Entries(s.model)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err)
|
||||||
|
}
|
||||||
|
token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow())
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
|
||||||
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
|
||||||
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
|
||||||
|
default:
|
||||||
|
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||||
|
switch apitokens.Evaluate(token, op, resource) {
|
||||||
|
case apitokens.DecisionAllow:
|
||||||
|
return token, nil
|
||||||
|
case apitokens.DecisionDeny:
|
||||||
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
||||||
|
case apitokens.DecisionPrompt:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventApprovalRequested,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
})
|
||||||
|
result, err := s.approvals.Request(ctx, token, op, resource)
|
||||||
|
if result.Rule != nil {
|
||||||
|
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
|
||||||
|
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventApprovalAllowed,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
})
|
||||||
|
return token, nil
|
||||||
|
case errors.Is(err, apiapproval.ErrRequestDenied):
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventApprovalDenied,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
})
|
||||||
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
||||||
|
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventApprovalCanceled,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
})
|
||||||
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
||||||
|
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventApprovalTimedOut,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
})
|
||||||
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||||
|
default:
|
||||||
|
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for i, entry := range s.model.Entries {
|
||||||
|
token, ok, err := apitokens.TokenFromEntry(entry)
|
||||||
|
if err != nil || !ok || token.ID != tokenID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hasPolicyRule(token.Policies, rule) {
|
||||||
|
token.Policies = append(token.Policies, rule)
|
||||||
|
}
|
||||||
|
s.model.Entries[i] = token.Entry(entry.Path)
|
||||||
|
s.dirty = true
|
||||||
|
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
||||||
|
lifecycle.Replace(s.model)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return status.Error(codes.NotFound, "api token entry not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyOperation(target string) apitokens.Operation {
|
||||||
|
switch clipboard.Target(target) {
|
||||||
|
case clipboard.TargetUsername:
|
||||||
|
return apitokens.OperationCopyUsername
|
||||||
|
case clipboard.TargetURL:
|
||||||
|
return apitokens.OperationCopyURL
|
||||||
|
default:
|
||||||
|
return apitokens.OperationCopyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
|
||||||
return func(
|
return func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req any,
|
req any,
|
||||||
info *grpc.UnaryServerInfo,
|
info *grpc.UnaryServerInfo,
|
||||||
handler grpc.UnaryHandler,
|
handler grpc.UnaryHandler,
|
||||||
) (any, error) {
|
) (any, error) {
|
||||||
md, ok := metadata.FromIncomingContext(ctx)
|
switch info.FullMethod {
|
||||||
if !ok {
|
case "/keepassgo.v1.VaultService/GetSessionStatus",
|
||||||
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
"/keepassgo.v1.VaultService/OpenVault",
|
||||||
|
"/keepassgo.v1.VaultService/OpenRemoteVault",
|
||||||
|
"/keepassgo.v1.VaultService/SaveVault",
|
||||||
|
"/keepassgo.v1.VaultService/LockVault",
|
||||||
|
"/keepassgo.v1.VaultService/UnlockVault":
|
||||||
|
if _, err := server.authenticateRequest(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
values := md.Get("authorization")
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "missing authorization")
|
|
||||||
}
|
|
||||||
|
|
||||||
if values[0] != "Bearer "+expectedToken {
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler(ctx, req)
|
return handler(ctx, req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
@@ -32,6 +38,243 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC)
|
||||||
|
token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
token.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
oldNow := timeNow
|
||||||
|
timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||||
|
defer func() { timeNow = oldNow }()
|
||||||
|
|
||||||
|
_, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if status.Code(err) != codes.Unauthenticated {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
respCh <- resp
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if pending.Operation != apitokens.OperationListEntries {
|
||||||
|
t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(allow) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := <-respCh
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(deny permanent) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-errCh; status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
service.mu.RLock()
|
||||||
|
tokens, err := apitokens.Entries(service.model)
|
||||||
|
service.mu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Entries() error = %v", err)
|
||||||
|
}
|
||||||
|
decision := apitokens.Evaluate(tokens[0], apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != apitokens.DecisionDeny {
|
||||||
|
t.Fatalf("Evaluate() after permanent deny = %q, want %q", decision, apitokens.DecisionDeny)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(cancel) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-errCh; status.Code(err) != codes.Unauthenticated {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(20 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if status.Code(err) != codes.DeadlineExceeded {
|
||||||
|
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
testAPITokenEntry(t),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
defer cleanup()
|
||||||
|
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||||
|
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval(allow permanent) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := service.AuditLog().Events()
|
||||||
|
if len(events) < 2 {
|
||||||
|
t.Fatalf("len(AuditLog().Events()) = %d, want at least 2", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != apiaudit.EventApprovalAllowed || events[1].Type != apiaudit.EventApprovalRequested {
|
||||||
|
t.Fatalf("AuditLog().Events() = %#v, want allowed then requested", events[:2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -39,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,7 +295,7 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSessionStatus() error = %v", err)
|
t.Fatalf("GetSessionStatus() error = %v", err)
|
||||||
@@ -108,7 +351,7 @@ func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||||
Path: "/tmp/test.kdbx",
|
Path: "/tmp/test.kdbx",
|
||||||
Password: lifecycle.unlockPassword,
|
Password: lifecycle.unlockPassword,
|
||||||
@@ -182,7 +425,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||||
Path: "/tmp/test.kdbx",
|
Path: "/tmp/test.kdbx",
|
||||||
Password: "correct horse battery staple",
|
Password: "correct horse battery staple",
|
||||||
@@ -211,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 {
|
||||||
@@ -228,7 +471,7 @@ func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -350,7 +593,7 @@ func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
err := tt.call(client, ctx)
|
err := tt.call(client, ctx)
|
||||||
if status.Code(err) != tt.want {
|
if status.Code(err) != tt.want {
|
||||||
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
|
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
|
||||||
@@ -365,7 +608,7 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListEntries() error = %v", err)
|
t.Fatalf("ListEntries() error = %v", err)
|
||||||
@@ -375,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")
|
||||||
@@ -389,7 +632,7 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
|
||||||
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,7 +679,7 @@ func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
|
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
|
||||||
ParentPath: []string{"Root"},
|
ParentPath: []string{"Root"},
|
||||||
Name: "Finance",
|
Name: "Finance",
|
||||||
@@ -472,7 +715,7 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GeneratePassword() error = %v", err)
|
t.Fatalf("GeneratePassword() error = %v", err)
|
||||||
@@ -489,7 +732,7 @@ func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
|
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
|
||||||
if status.Code(err) != codes.InvalidArgument {
|
if status.Code(err) != codes.InvalidArgument {
|
||||||
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||||
@@ -502,9 +745,9 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
|||||||
client, clipboardWriter, cleanup := newTestClient(t)
|
client, clipboardWriter, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
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)
|
||||||
@@ -521,14 +764,14 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
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",
|
||||||
},
|
},
|
||||||
@@ -539,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")
|
||||||
@@ -565,8 +808,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,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"}})
|
||||||
@@ -593,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +847,7 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListTemplates() error = %v", err)
|
t.Fatalf("ListTemplates() error = %v", err)
|
||||||
@@ -620,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",
|
||||||
},
|
},
|
||||||
@@ -636,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")
|
||||||
@@ -659,7 +902,7 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
|
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
|
||||||
Template: &keepassgov1.Entry{
|
Template: &keepassgov1.Entry{
|
||||||
Id: "website-login",
|
Id: "website-login",
|
||||||
@@ -712,8 +955,8 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
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)
|
||||||
}
|
}
|
||||||
@@ -722,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 {
|
||||||
@@ -739,18 +982,18 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
|||||||
client, _, cleanup := newTestClient(t)
|
client, _, cleanup := newTestClient(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -759,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 {
|
||||||
@@ -770,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)
|
||||||
}
|
}
|
||||||
@@ -785,44 +1028,72 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultTestTokenSecret = "test-token"
|
||||||
|
|
||||||
|
func tokenContext(secret string) context.Context {
|
||||||
|
return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecretForTest(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
token.SecretHash = hashSecretForTest(defaultTestTokenSecret)
|
||||||
|
token.Policies = append([]apitokens.PolicyRule(nil), rules...)
|
||||||
|
return token.Entry([]string{"Root", "API Tokens"})
|
||||||
|
}
|
||||||
|
|
||||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
model := vault.Model{
|
||||||
listener := bufconn.Listen(1024 * 1024)
|
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
|
||||||
keepassgov1.RegisterVaultServiceServer(server, NewServer(
|
|
||||||
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,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Templates: []vault.Entry{
|
Templates: []vault.Entry{
|
||||||
{
|
{
|
||||||
@@ -839,45 +1110,23 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Path: []string{"Templates"},
|
Path: []string{"Templates"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
passwords.DefaultProfiles(),
|
return newTestClientForModel(t, model)
|
||||||
clipboardWriter,
|
|
||||||
))
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_ = server.Serve(listener)
|
|
||||||
}()
|
|
||||||
|
|
||||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
|
||||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
|
||||||
return listener.Dial()
|
|
||||||
}),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("grpc.NewClient() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_ = conn.Close()
|
|
||||||
server.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||||
|
client, clipboardWriter, _, cleanup := newTestHarnessForModel(t, model)
|
||||||
|
return client, clipboardWriter, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
listener := bufconn.Listen(1024 * 1024)
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||||
lifecycle.model,
|
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||||
passwords.DefaultProfiles(),
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
clipboardWriter,
|
|
||||||
lifecycle,
|
|
||||||
))
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_ = server.Serve(listener)
|
_ = server.Serve(listener)
|
||||||
@@ -898,7 +1147,50 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
|
|||||||
server.Stop()
|
server.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||||
|
client, clipboardWriter, _, cleanup := newTestHarnessWithLifecycle(t, lifecycle)
|
||||||
|
return client, clipboardWriter, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
|
model := lifecycle.model
|
||||||
|
model.Entries = append(model.Entries, testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
))
|
||||||
|
lifecycle.model = model
|
||||||
|
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||||
|
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return listener.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.NewClient() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
server.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
type memoryClipboardWriter struct {
|
type memoryClipboardWriter struct {
|
||||||
@@ -910,6 +1202,21 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForServerPendingApproval(t *testing.T, server *Server, want int) []apiapproval.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pending := server.ApprovalBroker().Pending()
|
||||||
|
if len(pending) == want {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("server pending approvals never reached len %d", want)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type stubLifecycle struct {
|
type stubLifecycle struct {
|
||||||
model vault.Model
|
model vault.Model
|
||||||
openPath string
|
openPath string
|
||||||
@@ -984,3 +1291,7 @@ func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
|
|||||||
s.locked = false
|
s.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Replace(model vault.Model) {
|
||||||
|
s.model = model
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package apiapproval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRequestDenied = errors.New("authorization request denied")
|
||||||
|
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||||
|
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||||
|
ErrRequestNotFound = errors.New("authorization request not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutcomeAllowOnce Outcome = "allow-once"
|
||||||
|
OutcomeDenyOnce Outcome = "deny-once"
|
||||||
|
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||||
|
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||||
|
OutcomeCancel Outcome = "cancel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
ID string
|
||||||
|
TokenID string
|
||||||
|
TokenName string
|
||||||
|
ClientName string
|
||||||
|
Operation apitokens.Operation
|
||||||
|
Resource apitokens.Resource
|
||||||
|
RequestedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Outcome Outcome
|
||||||
|
Rule *apitokens.PolicyRule
|
||||||
|
}
|
||||||
|
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]*pendingRequest
|
||||||
|
timeout time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
nextID func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingRequest struct {
|
||||||
|
request Request
|
||||||
|
done chan Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
type idGenerator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counter int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBroker(timeout time.Duration) *Broker {
|
||||||
|
gen := &idGenerator{}
|
||||||
|
return &Broker{
|
||||||
|
pending: map[string]*pendingRequest{},
|
||||||
|
timeout: timeout,
|
||||||
|
now: func() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
},
|
||||||
|
nextID: func() string {
|
||||||
|
return gen.Next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *idGenerator) Next() string {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.counter++
|
||||||
|
return "approval-" + strconv.Itoa(g.counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Pending() []Request {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
requests := make([]Request, 0, len(b.pending))
|
||||||
|
for _, pending := range b.pending {
|
||||||
|
requests = append(requests, pending.request)
|
||||||
|
}
|
||||||
|
slices.SortFunc(requests, func(a, c Request) int {
|
||||||
|
switch {
|
||||||
|
case a.RequestedAt.Before(c.RequestedAt):
|
||||||
|
return -1
|
||||||
|
case a.RequestedAt.After(c.RequestedAt):
|
||||||
|
return 1
|
||||||
|
case a.ID < c.ID:
|
||||||
|
return -1
|
||||||
|
case a.ID > c.ID:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||||
|
if b == nil {
|
||||||
|
return Result{}, ErrRequestTimedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := &pendingRequest{
|
||||||
|
request: Request{
|
||||||
|
ID: b.nextID(),
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: op,
|
||||||
|
Resource: resource,
|
||||||
|
RequestedAt: b.now(),
|
||||||
|
},
|
||||||
|
done: make(chan Outcome, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pending[pending.request.ID] = pending
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.pending, pending.request.ID)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
timer := time.NewTimer(b.timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outcome := <-pending.done:
|
||||||
|
result, err := resultForOutcome(pending.request, outcome)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case <-timer.C:
|
||||||
|
return Result{}, ErrRequestTimedOut
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Result{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
pending, ok := b.pending[id]
|
||||||
|
b.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return Request{}, nil, ErrRequestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := RuleFromDecision(pending.request, outcome)
|
||||||
|
if err != nil {
|
||||||
|
return Request{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case pending.done <- outcome:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return pending.request, rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
|
||||||
|
rule, err := RuleFromDecision(request, outcome)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
result := Result{Outcome: outcome, Rule: rule}
|
||||||
|
switch outcome {
|
||||||
|
case OutcomeAllowOnce, OutcomeAllowPermanent:
|
||||||
|
return result, nil
|
||||||
|
case OutcomeDenyOnce, OutcomeDenyPermanent:
|
||||||
|
return result, ErrRequestDenied
|
||||||
|
case OutcomeCancel:
|
||||||
|
return result, ErrRequestCanceled
|
||||||
|
default:
|
||||||
|
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
|
||||||
|
switch outcome {
|
||||||
|
case OutcomeAllowPermanent:
|
||||||
|
rule := apitokens.PolicyRule{
|
||||||
|
Effect: apitokens.EffectAllow,
|
||||||
|
Operation: request.Operation,
|
||||||
|
Resource: request.Resource,
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
case OutcomeDenyPermanent:
|
||||||
|
rule := apitokens.PolicyRule{
|
||||||
|
Effect: apitokens.EffectDeny,
|
||||||
|
Operation: request.Operation,
|
||||||
|
Resource: request.Resource,
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package apiapproval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(time.Minute)
|
||||||
|
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
resultCh := make(chan Result, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||||
|
resultCh <- result
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForPending(t, broker, 1)
|
||||||
|
pending := broker.Pending()
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("Pending() len = %d, want 1", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].TokenID != "token-1" {
|
||||||
|
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := <-resultCh
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("Request() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if result.Outcome != OutcomeAllowOnce {
|
||||||
|
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
|
||||||
|
}
|
||||||
|
if result.Rule != nil {
|
||||||
|
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
|
||||||
|
}
|
||||||
|
if got := broker.Pending(); len(got) != 0 {
|
||||||
|
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(time.Minute)
|
||||||
|
reqDone := make(chan struct{})
|
||||||
|
var result Result
|
||||||
|
var err error
|
||||||
|
go func() {
|
||||||
|
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
|
||||||
|
close(reqDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForPending(t, broker, 1)
|
||||||
|
pending := broker.Pending()[0]
|
||||||
|
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
|
||||||
|
if resolveErr != nil {
|
||||||
|
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
|
||||||
|
}
|
||||||
|
if request.ID != pending.ID {
|
||||||
|
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
|
||||||
|
}
|
||||||
|
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
|
||||||
|
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
<-reqDone
|
||||||
|
if !errors.Is(err, ErrRequestDenied) {
|
||||||
|
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
|
||||||
|
}
|
||||||
|
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
|
||||||
|
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
|
||||||
|
}
|
||||||
|
if result.Outcome != OutcomeDenyPermanent {
|
||||||
|
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerSupportsCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(time.Minute)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForPending(t, broker, 1)
|
||||||
|
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
|
||||||
|
t.Fatalf("Resolve(cancel) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
|
||||||
|
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(10 * time.Millisecond)
|
||||||
|
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||||
|
if !errors.Is(err, ErrRequestTimedOut) {
|
||||||
|
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
|
||||||
|
}
|
||||||
|
if got := broker.Pending(); len(got) != 0 {
|
||||||
|
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if got := len(broker.Pending()); got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("Pending() never reached len %d", want)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package apiaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventApprovalRequested EventType = "approval_requested"
|
||||||
|
EventApprovalAllowed EventType = "approval_allowed"
|
||||||
|
EventApprovalDenied EventType = "approval_denied"
|
||||||
|
EventApprovalCanceled EventType = "approval_canceled"
|
||||||
|
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||||
|
EventAutofillFound EventType = "autofill_found"
|
||||||
|
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||||
|
EventAutofillBlocked EventType = "autofill_blocked"
|
||||||
|
EventAuthRejected EventType = "auth_rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type EventType
|
||||||
|
At time.Time
|
||||||
|
TokenID string
|
||||||
|
TokenName string
|
||||||
|
ClientName string
|
||||||
|
Operation apitokens.Operation
|
||||||
|
Resource apitokens.Resource
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Log struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
max int
|
||||||
|
now func() time.Time
|
||||||
|
events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(max int) *Log {
|
||||||
|
if max < 1 {
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
return &Log{
|
||||||
|
max: max,
|
||||||
|
now: func() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) Record(event Event) {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if event.At.IsZero() {
|
||||||
|
event.At = l.now()
|
||||||
|
}
|
||||||
|
l.events = append([]Event{event}, l.events...)
|
||||||
|
if len(l.events) > l.max {
|
||||||
|
l.events = l.events[:l.max]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) Events() []Event {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return slices.Clone(l.events)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package apiaudit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log := New(2)
|
||||||
|
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
|
||||||
|
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
|
||||||
|
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
|
||||||
|
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
|
||||||
|
|
||||||
|
events := log.Events()
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("len(Events()) = %d, want 2", len(events))
|
||||||
|
}
|
||||||
|
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
|
||||||
|
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogPreservesRecordedMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log := New(5)
|
||||||
|
log.Record(Event{
|
||||||
|
Type: EventApprovalRequested,
|
||||||
|
TokenID: "token-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationListEntries,
|
||||||
|
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
|
||||||
|
Message: "prompted for access",
|
||||||
|
})
|
||||||
|
|
||||||
|
events := log.Events()
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
|
||||||
|
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogStoresAutofillEventTypes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log := New(5)
|
||||||
|
log.Record(Event{
|
||||||
|
Type: EventAutofillAmbiguous,
|
||||||
|
TokenName: "Browser Extension",
|
||||||
|
Message: "multiple matches for example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
events := log.Events()
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("len(Events()) = %d, want 1", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != EventAutofillAmbiguous {
|
||||||
|
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EntryTypeAPIToken = "api-token"
|
||||||
|
|
||||||
|
FieldType = "KeePassGO-Type"
|
||||||
|
FieldTokenID = "KeePassGO-API-Token-ID"
|
||||||
|
FieldClientName = "KeePassGO-API-Client-Name"
|
||||||
|
FieldCreatedAt = "KeePassGO-API-Created-At"
|
||||||
|
FieldExpiresAt = "KeePassGO-API-Expires-At"
|
||||||
|
FieldDisabled = "KeePassGO-API-Disabled"
|
||||||
|
FieldRevokedAt = "KeePassGO-API-Revoked-At"
|
||||||
|
FieldSecretHash = "KeePassGO-API-Secret-Hash"
|
||||||
|
FieldPolicies = "KeePassGO-API-Policies"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotAToken = errors.New("entry is not an api token")
|
||||||
|
ErrInvalidToken = errors.New("invalid api token")
|
||||||
|
ErrExpiredToken = errors.New("expired api token")
|
||||||
|
ErrDisabledToken = errors.New("disabled api token")
|
||||||
|
ErrTokenNotFound = errors.New("api token not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
var EntryPath = []string{"Root", "API Tokens"}
|
||||||
|
|
||||||
|
type Effect string
|
||||||
|
type Operation string
|
||||||
|
type ResourceKind string
|
||||||
|
type Decision string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EffectAllow Effect = "allow"
|
||||||
|
EffectDeny Effect = "deny"
|
||||||
|
|
||||||
|
ResourceGroup ResourceKind = "group"
|
||||||
|
ResourceEntry ResourceKind = "entry"
|
||||||
|
|
||||||
|
DecisionAllow Decision = "allow"
|
||||||
|
DecisionDeny Decision = "deny"
|
||||||
|
DecisionPrompt Decision = "prompt"
|
||||||
|
|
||||||
|
OperationListEntries Operation = "list_entries"
|
||||||
|
OperationListGroups Operation = "list_groups"
|
||||||
|
OperationReadEntry Operation = "read_entry"
|
||||||
|
OperationCopyPassword Operation = "copy_password"
|
||||||
|
OperationCopyUsername Operation = "copy_username"
|
||||||
|
OperationCopyURL Operation = "copy_url"
|
||||||
|
OperationMutateEntry Operation = "mutate_entry"
|
||||||
|
OperationMutateGroup Operation = "mutate_group"
|
||||||
|
OperationManageVault Operation = "manage_vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
Kind ResourceKind `json:"kind"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
|
EntryID string `json:"entry_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolicyRule struct {
|
||||||
|
Effect Effect `json:"effect"`
|
||||||
|
Operation Operation `json:"operation"`
|
||||||
|
Resource Resource `json:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
ClientName string
|
||||||
|
SecretHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
Disabled bool
|
||||||
|
Policies []PolicyRule
|
||||||
|
}
|
||||||
|
|
||||||
|
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
|
||||||
|
clear, hashed, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
id, _, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
return Token{
|
||||||
|
ID: id,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
ClientName: strings.TrimSpace(clientName),
|
||||||
|
SecretHash: hashed,
|
||||||
|
CreatedAt: now.UTC(),
|
||||||
|
ExpiresAt: cloneTime(expiresAt),
|
||||||
|
}, clear, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Rotate(token Token, now time.Time) (Token, string, error) {
|
||||||
|
clear, hashed, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
token.SecretHash = hashed
|
||||||
|
token.Disabled = false
|
||||||
|
token.RevokedAt = nil
|
||||||
|
if token.CreatedAt.IsZero() {
|
||||||
|
token.CreatedAt = now.UTC()
|
||||||
|
}
|
||||||
|
return token, clear, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Disable(token Token) Token {
|
||||||
|
token.Disabled = true
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func Revoke(token Token, when time.Time) Token {
|
||||||
|
token.Disabled = true
|
||||||
|
t := when.UTC()
|
||||||
|
token.RevokedAt = &t
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
|
||||||
|
hashed := hashSecret(presentedSecret)
|
||||||
|
for _, token := range tokens {
|
||||||
|
if token.SecretHash != hashed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token.Disabled || token.RevokedAt != nil {
|
||||||
|
return Token{}, ErrDisabledToken
|
||||||
|
}
|
||||||
|
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
|
||||||
|
return Token{}, ErrExpiredToken
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return Token{}, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func Evaluate(token Token, operation Operation, resource Resource) Decision {
|
||||||
|
decision := DecisionPrompt
|
||||||
|
for _, rule := range token.Policies {
|
||||||
|
if rule.Operation != operation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matches(rule.Resource, resource) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Effect == EffectDeny {
|
||||||
|
return DecisionDeny
|
||||||
|
}
|
||||||
|
if rule.Effect == EffectAllow {
|
||||||
|
decision = DecisionAllow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func Entries(model vault.Model) ([]Token, error) {
|
||||||
|
var out []Token
|
||||||
|
for _, entry := range model.Entries {
|
||||||
|
token, ok, err := TokenFromEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
out = append(out, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Token) int {
|
||||||
|
switch {
|
||||||
|
case a.Name < b.Name:
|
||||||
|
return -1
|
||||||
|
case a.Name > b.Name:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return strings.Compare(a.ID, b.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Find(model vault.Model, id string) (Token, error) {
|
||||||
|
tokens, err := Entries(model)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
for _, token := range tokens {
|
||||||
|
if token.ID == id {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Token{}, ErrTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func Upsert(model *vault.Model, token Token) {
|
||||||
|
model.UpsertEntry(token.Entry(EntryPath))
|
||||||
|
model.CreateGroup([]string{"Root"}, "API Tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Delete(model *vault.Model, id string) error {
|
||||||
|
for i, entry := range model.Entries {
|
||||||
|
token, ok, err := TokenFromEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok && token.ID == id {
|
||||||
|
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
|
||||||
|
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||||
|
return Token{}, false, nil
|
||||||
|
}
|
||||||
|
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse created at: %w", err)
|
||||||
|
}
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
|
||||||
|
}
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
var revokedAt *time.Time
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
|
||||||
|
}
|
||||||
|
revokedAt = &t
|
||||||
|
}
|
||||||
|
policies := []PolicyRule{}
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse policies: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Token{
|
||||||
|
ID: entry.Fields[FieldTokenID],
|
||||||
|
Name: entry.Title,
|
||||||
|
ClientName: entry.Fields[FieldClientName],
|
||||||
|
SecretHash: entry.Fields[FieldSecretHash],
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
RevokedAt: revokedAt,
|
||||||
|
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
|
||||||
|
Policies: policies,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Token) Entry(path []string) vault.Entry {
|
||||||
|
fields := map[string]string{
|
||||||
|
FieldType: EntryTypeAPIToken,
|
||||||
|
FieldTokenID: t.ID,
|
||||||
|
FieldClientName: t.ClientName,
|
||||||
|
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
|
||||||
|
FieldSecretHash: t.SecretHash,
|
||||||
|
}
|
||||||
|
if t.ExpiresAt != nil {
|
||||||
|
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t.RevokedAt != nil {
|
||||||
|
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if len(t.Policies) > 0 {
|
||||||
|
data, _ := json.Marshal(t.Policies)
|
||||||
|
fields[FieldPolicies] = string(data)
|
||||||
|
}
|
||||||
|
return vault.Entry{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Name,
|
||||||
|
Username: t.ClientName,
|
||||||
|
Path: slices.Clone(path),
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSecret() (string, string, error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate secret: %w", err)
|
||||||
|
}
|
||||||
|
clear := base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
return clear, hashSecret(clear), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTime(in *time.Time) *time.Time {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := in.UTC()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(rule, resource Resource) bool {
|
||||||
|
switch rule.Kind {
|
||||||
|
case ResourceEntry:
|
||||||
|
return rule.EntryID != "" && rule.EntryID == resource.EntryID
|
||||||
|
case ResourceGroup:
|
||||||
|
if len(rule.Path) > len(resource.Path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Name: "Browser Connector",
|
||||||
|
ClientName: "browser-extension",
|
||||||
|
SecretHash: "deadbeef",
|
||||||
|
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
Disabled: true,
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := token.Entry([]string{"Root", "API Tokens"})
|
||||||
|
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||||
|
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := TokenFromEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TokenFromEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("TokenFromEntry() ok = false, want true")
|
||||||
|
}
|
||||||
|
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
|
||||||
|
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
|
||||||
|
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
|
||||||
|
}
|
||||||
|
if len(got.Policies) != 2 {
|
||||||
|
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Ordinary Entry"},
|
||||||
|
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Entries(model)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Entries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(Entries()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Name != "Browser" || got[1].Name != "CLI" {
|
||||||
|
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{}
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Name: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
SecretHash: "hash-1",
|
||||||
|
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
Upsert(&model, token)
|
||||||
|
if len(model.Entries) != 1 {
|
||||||
|
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
|
||||||
|
}
|
||||||
|
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
|
||||||
|
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ClientName = "rotated-client"
|
||||||
|
token.Disabled = true
|
||||||
|
Upsert(&model, token)
|
||||||
|
|
||||||
|
got, err := Find(model, "token-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Find() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.ClientName != "rotated-client" || !got.Disabled {
|
||||||
|
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
|
||||||
|
{ID: "entry-1", Title: "Regular Entry"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Delete(&model, "token-1"); err != nil {
|
||||||
|
t.Fatalf("Delete() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
|
||||||
|
}
|
||||||
|
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
|
||||||
|
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
if token.ID == "" || secret == "" {
|
||||||
|
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
|
||||||
|
}
|
||||||
|
if token.SecretHash == secret {
|
||||||
|
t.Fatal("SecretHash should not equal cleartext secret")
|
||||||
|
}
|
||||||
|
if token.Disabled {
|
||||||
|
t.Fatal("Disabled = true, want false after Issue")
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rotate() error = %v", err)
|
||||||
|
}
|
||||||
|
if rotated.ID != token.ID {
|
||||||
|
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
|
||||||
|
}
|
||||||
|
if newSecret == secret {
|
||||||
|
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
|
||||||
|
}
|
||||||
|
if rotated.SecretHash == token.SecretHash {
|
||||||
|
t.Fatal("Rotate() left SecretHash unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled := Disable(rotated)
|
||||||
|
if !disabled.Disabled {
|
||||||
|
t.Fatal("Disable() did not set Disabled")
|
||||||
|
}
|
||||||
|
revoked := Revoke(disabled, now.Add(2*time.Hour))
|
||||||
|
if !revoked.Disabled {
|
||||||
|
t.Fatal("Revoke() should leave token disabled")
|
||||||
|
}
|
||||||
|
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
|
||||||
|
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue(expired) error = %v", err)
|
||||||
|
}
|
||||||
|
expiredAt := now.Add(-time.Minute)
|
||||||
|
expired.ExpiresAt = &expiredAt
|
||||||
|
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue(disabled) error = %v", err)
|
||||||
|
}
|
||||||
|
disabled = Disable(disabled)
|
||||||
|
|
||||||
|
tokens := []Token{expired, disabled, valid}
|
||||||
|
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
|
||||||
|
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
|
||||||
|
}
|
||||||
|
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
|
||||||
|
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
|
||||||
|
}
|
||||||
|
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
|
||||||
|
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
|
||||||
|
}
|
||||||
|
got, err := Authenticate(tokens, secret, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Authenticate(valid) error = %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != valid.ID {
|
||||||
|
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionAllow {
|
||||||
|
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionDeny {
|
||||||
|
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionPrompt {
|
||||||
|
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
|
||||||
|
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
|
||||||
|
if allowed != DecisionAllow || denied != DecisionDeny {
|
||||||
|
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Name: "CLI",
|
||||||
|
ClientName: "cli",
|
||||||
|
SecretHash: "hash",
|
||||||
|
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
|
||||||
|
}
|
||||||
|
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
|
||||||
|
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
|
||||||
|
}) {
|
||||||
|
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
)
|
)
|
||||||
@@ -21,6 +24,9 @@ const (
|
|||||||
SectionEntries Section = ""
|
SectionEntries Section = ""
|
||||||
SectionTemplates Section = "templates"
|
SectionTemplates Section = "templates"
|
||||||
SectionRecycleBin Section = "recycle-bin"
|
SectionRecycleBin Section = "recycle-bin"
|
||||||
|
SectionAPITokens Section = "api-tokens"
|
||||||
|
SectionAPIAudit Section = "api-audit"
|
||||||
|
SectionAbout Section = "about"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CurrentSession interface {
|
type CurrentSession interface {
|
||||||
@@ -56,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
|
||||||
@@ -81,8 +88,19 @@ type RemoteOpenableSession interface {
|
|||||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecurityConfigurableSession interface {
|
||||||
|
ConfigureSecurity(vault.SecuritySettings) error
|
||||||
|
SecuritySettings() vault.SecuritySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovalManager interface {
|
||||||
|
Pending() []apiapproval.Request
|
||||||
|
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
|
Approvals ApprovalManager
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -92,6 +110,161 @@ type State struct {
|
|||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) PendingApprovals() []apiapproval.Request {
|
||||||
|
if s.Approvals == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Approvals.Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
|
||||||
|
if s.Approvals == nil {
|
||||||
|
return fmt.Errorf("approval manager is not configured")
|
||||||
|
}
|
||||||
|
_, _, err := s.Approvals.Resolve(id, outcome)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return apitokens.Entries(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, "", err
|
||||||
|
}
|
||||||
|
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, "", err
|
||||||
|
}
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return token, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, "", err
|
||||||
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, "", err
|
||||||
|
}
|
||||||
|
token, secret, err := apitokens.Rotate(token, now)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, "", err
|
||||||
|
}
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return token, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DisableAPIToken(id string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apitokens.Upsert(&model, apitokens.Disable(token))
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) DeleteAPIToken(id string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apitokens.Delete(&model, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||||
|
security, ok := s.Session.(SecurityConfigurableSession)
|
||||||
|
if !ok {
|
||||||
|
return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings")
|
||||||
|
}
|
||||||
|
return security.SecuritySettings(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||||
|
security, ok := s.Session.(SecurityConfigurableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session does not expose security settings")
|
||||||
|
}
|
||||||
|
if err := security.ConfigureSecurity(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) ShowSection(section Section) {
|
func (s *State) ShowSection(section Section) {
|
||||||
s.Section = section
|
s.Section = section
|
||||||
s.CurrentPath = nil
|
s.CurrentPath = nil
|
||||||
@@ -131,7 +304,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Section == SectionEntries {
|
if s.Section == SectionEntries {
|
||||||
return model.EntriesInPath(s.CurrentPath), nil
|
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||||
}
|
}
|
||||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||||
return entries, nil
|
return entries, nil
|
||||||
@@ -204,6 +377,8 @@ 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, SectionAbout:
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
return slices.Clone(model.Entries)
|
return slices.Clone(model.Entries)
|
||||||
}
|
}
|
||||||
@@ -549,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 {
|
||||||
@@ -664,6 +851,27 @@ func (s *State) CreateGroup(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) MoveCurrentGroup(parent []string) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current := append([]string(nil), s.CurrentPath...)
|
||||||
|
if err := model.MoveGroup(current, parent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session.Replace(model)
|
||||||
|
if len(current) > 0 {
|
||||||
|
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
||||||
|
}
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) RenameCurrentGroup(newName string) error {
|
func (s *State) RenameCurrentGroup(newName string) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
@@ -17,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()
|
||||||
@@ -36,8 +39,128 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||||
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Crew"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
titles := make([]string, 0, len(got))
|
||||||
|
for _, entry := range got {
|
||||||
|
titles = append(titles, entry.Title)
|
||||||
|
}
|
||||||
|
if !slices.Equal(titles, []string{"Crew Note"}) {
|
||||||
|
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingApprovalsReturnsManagerRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Approvals: &stubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{
|
||||||
|
{ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := state.PendingApprovals()
|
||||||
|
if len(got) != 1 || got[0].ID != "approval-1" {
|
||||||
|
t.Fatalf("PendingApprovals() = %#v, want approval-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveApprovalDelegatesToManager(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &stubApprovalManager{}
|
||||||
|
state := State{Approvals: manager}
|
||||||
|
if err := state.ResolveApproval("approval-1", apiapproval.OutcomeAllowPermanent); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval() error = %v", err)
|
||||||
|
}
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeAllowPermanent {
|
||||||
|
t.Fatalf("ResolveApproval() delegated (%q, %q), want (approval-1, allow-permanent)", manager.lastID, manager.lastOutcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
session := &mutableStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: session}
|
||||||
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
issued, secret, err := state.IssueAPIToken("CLI", "grpc-cli", &expiresAt, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueAPIToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if issued.ID == "" || secret == "" {
|
||||||
|
t.Fatalf("IssueAPIToken() = %#v, %q, want non-empty id and secret", issued, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := state.APITokens()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("APITokens() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 1 || tokens[0].ID != issued.ID {
|
||||||
|
t.Fatalf("APITokens() = %#v, want issued token", tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, rotatedSecret, err := state.RotateAPIToken(issued.ID, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RotateAPIToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if rotated.ID != issued.ID || rotatedSecret == "" || rotatedSecret == secret {
|
||||||
|
t.Fatalf("RotateAPIToken() = %#v, %q, want same id and new secret", rotated, rotatedSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.DisableAPIToken(issued.ID); err != nil {
|
||||||
|
t.Fatalf("DisableAPIToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := state.RevokeAPIToken(issued.ID, now.Add(2*time.Hour)); err != nil {
|
||||||
|
t.Fatalf("RevokeAPIToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err = state.APITokens()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("APITokens() after revoke error = %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 1 || !tokens[0].Disabled || tokens[0].RevokedAt == nil {
|
||||||
|
t.Fatalf("APITokens() after revoke = %#v, want disabled revoked token", tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.DeleteAPIToken(issued.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAPIToken() error = %v", err)
|
||||||
|
}
|
||||||
|
tokens, err = state.APITokens()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("APITokens() after delete error = %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 0 {
|
||||||
|
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,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()
|
||||||
@@ -63,11 +186,46 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Crew"},
|
||||||
|
SearchQuery: "missing",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() with search error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SearchQuery = ""
|
||||||
|
got, err = state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
|
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -191,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"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -260,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()
|
||||||
@@ -312,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 {
|
||||||
@@ -366,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 {
|
||||||
@@ -388,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 {
|
||||||
@@ -403,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"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -412,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,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 {
|
||||||
@@ -442,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()
|
||||||
@@ -462,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 {
|
||||||
@@ -494,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 {
|
||||||
@@ -509,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 {
|
||||||
@@ -585,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))
|
||||||
@@ -614,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 {
|
||||||
@@ -633,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 {
|
||||||
@@ -648,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 {
|
||||||
@@ -704,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,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 {
|
||||||
@@ -842,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,
|
||||||
@@ -860,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,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")
|
||||||
@@ -988,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"})
|
||||||
@@ -1058,6 +1216,27 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
session := &mutableStubSession{model: vault.Model{}}
|
||||||
|
state := State{
|
||||||
|
Session: session,
|
||||||
|
CurrentPath: []string{"Root"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.CreateGroup("Infrastructure / Prod"); err != nil {
|
||||||
|
t.Fatalf("CreateGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||||
|
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
||||||
|
}
|
||||||
|
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||||
|
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
|
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1118,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 {
|
||||||
@@ -1134,14 +1313,45 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
|
||||||
|
|
||||||
|
session := &mutableStubSession{model: model}
|
||||||
|
state := State{
|
||||||
|
Session: session,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
|
||||||
|
t.Fatalf("MoveCurrentGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
|
||||||
|
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
|
||||||
|
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1149,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 {
|
||||||
@@ -1177,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"))
|
||||||
@@ -1203,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 {
|
||||||
@@ -1229,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"))
|
||||||
@@ -1247,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 {
|
||||||
@@ -1273,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")
|
||||||
@@ -1301,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"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1374,7 +1584,43 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubApprovalManager struct {
|
||||||
|
pending []apiapproval.Request
|
||||||
|
lastID string
|
||||||
|
lastOutcome apiapproval.Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubApprovalManager) Pending() []apiapproval.Request {
|
||||||
|
return append([]apiapproval.Request(nil), s.pending...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
|
s.lastID = id
|
||||||
|
s.lastOutcome = outcome
|
||||||
|
return apiapproval.Request{ID: id}, nil, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.png *.svg
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
func MustPNG(name string) image.Image {
|
||||||
|
data, err := files.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
|
||||||
|
<title>KeePassGO icon</title>
|
||||||
|
<desc>Vault-shaped mark with lock, layered record lines, and navigation accent.</desc>
|
||||||
|
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||||
|
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||||
|
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||||
|
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
|
||||||
|
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||||
|
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||||
|
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||||
|
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||||
|
<circle cx="128" cy="138" r="8" fill="#FFFFFF" opacity="0.2"/>
|
||||||
|
|
||||||
|
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||||
|
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="260" viewBox="0 0 920 260" fill="none">
|
||||||
|
<title>KeePassGO horizontal logo</title>
|
||||||
|
<desc>KeePassGO symbol with wordmark for light-theme desktop UI.</desc>
|
||||||
|
<defs>
|
||||||
|
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||||
|
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||||
|
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||||
|
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||||
|
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||||
|
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||||
|
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||||
|
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||||
|
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||||
|
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
<rect width="920" height="260" fill="transparent"/>
|
||||||
|
<use href="#kpg-icon" x="24" y="20" width="176" height="176"/>
|
||||||
|
<text x="220" y="132" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="84" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" fill="none">
|
||||||
|
<title>KeePassGO splash screen</title>
|
||||||
|
<desc>Light-theme desktop splash screen with structured panels and the KeePassGO logo.</desc>
|
||||||
|
<defs>
|
||||||
|
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||||
|
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||||
|
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||||
|
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||||
|
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||||
|
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||||
|
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||||
|
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||||
|
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||||
|
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||||
|
</symbol>
|
||||||
|
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#D9E0E6" stop-opacity="0.9"/>
|
||||||
|
<stop offset="1" stop-color="#D9E0E6" stop-opacity="0.2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1280" height="800" fill="#F7F7F5"/>
|
||||||
|
|
||||||
|
<g opacity="0.9">
|
||||||
|
<rect x="48" y="48" width="238" height="188" fill="#EEF2F4"/>
|
||||||
|
<rect x="286" y="48" width="152" height="104" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="438" y="48" width="214" height="104" fill="#E7EDF1"/>
|
||||||
|
<rect x="652" y="48" width="146" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="798" y="48" width="224" height="118" fill="#EEF2F4"/>
|
||||||
|
<rect x="1022" y="48" width="210" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
|
||||||
|
<rect x="48" y="236" width="128" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="176" y="236" width="262" height="164" fill="#E7EDF1"/>
|
||||||
|
<rect x="438" y="236" width="160" height="84" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="598" y="236" width="200" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="798" y="236" width="434" height="164" fill="#EEF2F4"/>
|
||||||
|
|
||||||
|
<rect x="48" y="400" width="224" height="128" fill="#EEF2F4"/>
|
||||||
|
<rect x="272" y="400" width="166" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="438" y="400" width="360" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="798" y="400" width="152" height="128" fill="#E7EDF1"/>
|
||||||
|
<rect x="950" y="400" width="282" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
|
||||||
|
<rect x="48" y="528" width="114" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="162" y="528" width="276" height="224" fill="#E7EDF1"/>
|
||||||
|
<rect x="438" y="528" width="212" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="650" y="528" width="318" height="224" fill="#EEF2F4"/>
|
||||||
|
<rect x="968" y="528" width="264" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g opacity="0.45">
|
||||||
|
<path d="M48 152H1232" stroke="#C7D0D8"/>
|
||||||
|
<path d="M48 320H1232" stroke="#C7D0D8"/>
|
||||||
|
<path d="M48 528H1232" stroke="#C7D0D8"/>
|
||||||
|
<path d="M176 48V752" stroke="#C7D0D8"/>
|
||||||
|
<path d="M438 48V752" stroke="#C7D0D8"/>
|
||||||
|
<path d="M798 48V752" stroke="#C7D0D8"/>
|
||||||
|
<path d="M1022 48V752" stroke="#C7D0D8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="290" y="190" width="700" height="420" rx="18" fill="#F7F7F5" opacity="0.92"/>
|
||||||
|
<rect x="290" y="190" width="700" height="420" rx="18" stroke="#C7D0D8"/>
|
||||||
|
<rect x="290" y="190" width="700" height="120" rx="18" fill="url(#fade)"/>
|
||||||
|
|
||||||
|
<use href="#kpg-icon" x="530" y="232" width="220" height="220"/>
|
||||||
|
<text x="640" y="530" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||||
|
<text x="640" y="582" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
|
||||||
|
|
||||||
|
<g opacity="0.9">
|
||||||
|
<rect x="516" y="634" width="248" height="10" rx="5" fill="#D9E0E6"/>
|
||||||
|
<rect x="516" y="634" width="132" height="10" rx="5" fill="#6D89A8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
|
||||||
|
<title>KeePassGO square splash preview</title>
|
||||||
|
<desc>Square crop of the KeePassGO splash system.</desc>
|
||||||
|
<defs>
|
||||||
|
<symbol id="kpg-icon" viewBox="0 0 256 256">
|
||||||
|
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
|
||||||
|
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
|
||||||
|
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
|
||||||
|
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
|
||||||
|
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
|
||||||
|
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
|
||||||
|
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
|
||||||
|
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
|
||||||
|
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
|
||||||
|
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1024" height="1024" fill="#F7F7F5"/>
|
||||||
|
<g opacity="0.9">
|
||||||
|
<rect x="32" y="32" width="180" height="184" fill="#EEF2F4"/>
|
||||||
|
<rect x="212" y="32" width="264" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="476" y="32" width="180" height="184" fill="#E7EDF1"/>
|
||||||
|
<rect x="656" y="32" width="336" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="656" y="172" width="336" height="180" fill="#EEF2F4"/>
|
||||||
|
<rect x="32" y="216" width="236" height="210" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="268" y="216" width="388" height="210" fill="#EEF2F4"/>
|
||||||
|
<rect x="32" y="426" width="180" height="268" fill="#E7EDF1"/>
|
||||||
|
<rect x="212" y="426" width="312" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="524" y="426" width="468" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="32" y="694" width="280" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
<rect x="312" y="694" width="268" height="298" fill="#EEF2F4"/>
|
||||||
|
<rect x="580" y="694" width="412" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
|
||||||
|
</g>
|
||||||
|
<rect x="162" y="190" width="700" height="644" rx="24" fill="#F7F7F5" opacity="0.95"/>
|
||||||
|
<rect x="162" y="190" width="700" height="644" rx="24" stroke="#C7D0D8"/>
|
||||||
|
<use href="#kpg-icon" x="332" y="252" width="360" height="360"/>
|
||||||
|
<text x="512" y="680" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
|
||||||
|
<text x="512" y="740" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,296 @@
|
|||||||
|
package autofillcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Targets []string `json:"targets,omitempty"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Entries []Entry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MatchStatusNone MatchStatus = ""
|
||||||
|
MatchStatusFound MatchStatus = "found"
|
||||||
|
MatchStatusAmbiguous MatchStatus = "ambiguous"
|
||||||
|
MatchStatusMissing MatchStatus = "missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchResult struct {
|
||||||
|
Status MatchStatus `json:"status"`
|
||||||
|
Entry Entry `json:"entry,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Match(cache File, webURL string) (Entry, bool) {
|
||||||
|
result := Resolve(cache, webURL)
|
||||||
|
return result.Entry, result.Status == MatchStatusFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func Resolve(cache File, webURL string) MatchResult {
|
||||||
|
target := normalizeURL(webURL)
|
||||||
|
if target.host == "" {
|
||||||
|
return MatchResult{Status: MatchStatusMissing}
|
||||||
|
}
|
||||||
|
|
||||||
|
exactHost := make([]Entry, 0)
|
||||||
|
parentHost := make([]Entry, 0)
|
||||||
|
for _, entry := range cache.Entries {
|
||||||
|
if entryMatchesHost(entry, target.host) {
|
||||||
|
exactHost = append(exactHost, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entryMatchesParentHost(entry, target.host) {
|
||||||
|
parentHost = append(parentHost, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return chooseEntry(target, parentHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Build(model vault.Model, now time.Time) File {
|
||||||
|
entries := make([]Entry, 0, len(model.Entries))
|
||||||
|
for _, item := range model.Entries {
|
||||||
|
targets := collectTargets(item)
|
||||||
|
host := normalizeHost(item.URL)
|
||||||
|
if host == "" {
|
||||||
|
for _, target := range targets {
|
||||||
|
host = normalizeHost(target)
|
||||||
|
if host != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: item.ID,
|
||||||
|
Title: item.Title,
|
||||||
|
Username: item.Username,
|
||||||
|
Password: item.Password,
|
||||||
|
URL: item.URL,
|
||||||
|
Host: host,
|
||||||
|
Targets: targets,
|
||||||
|
Path: append([]string(nil), item.Path...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return File{
|
||||||
|
UpdatedAt: now.UTC().Format(time.RFC3339),
|
||||||
|
Entries: entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Write(path string, model vault.Model, now time.Time) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(Build(model, now), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Clear(path string) error {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHost(raw string) string {
|
||||||
|
return normalizeURL(raw).host
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedTarget struct {
|
||||||
|
host string
|
||||||
|
path string
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeURL(raw string) normalizedTarget {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return normalizedTarget{}
|
||||||
|
}
|
||||||
|
if !strings.Contains(value, "://") {
|
||||||
|
value = "https://" + value
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedTarget{}
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(parsed.Hostname())
|
||||||
|
path := cleanPath(parsed.EscapedPath())
|
||||||
|
return normalizedTarget{
|
||||||
|
host: strings.ToLower(host),
|
||||||
|
path: path,
|
||||||
|
url: strings.ToLower(host) + path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" || path == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
|
||||||
|
switch len(entries) {
|
||||||
|
case 0:
|
||||||
|
return MatchResult{Status: MatchStatusMissing}
|
||||||
|
case 1:
|
||||||
|
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
exact := make([]Entry, 0)
|
||||||
|
bestPrefixLen := -1
|
||||||
|
bestPrefix := make([]Entry, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
exactMatch, prefixLen := bestTargetMatch(entry, target)
|
||||||
|
if exactMatch {
|
||||||
|
exact = append(exact, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prefixLen <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case prefixLen > bestPrefixLen:
|
||||||
|
bestPrefixLen = prefixLen
|
||||||
|
bestPrefix = []Entry{entry}
|
||||||
|
case prefixLen == bestPrefixLen:
|
||||||
|
bestPrefix = append(bestPrefix, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(exact) == 1 {
|
||||||
|
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
|
||||||
|
}
|
||||||
|
if len(exact) > 1 {
|
||||||
|
return MatchResult{Status: MatchStatusAmbiguous}
|
||||||
|
}
|
||||||
|
if len(bestPrefix) == 1 {
|
||||||
|
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
|
||||||
|
}
|
||||||
|
if len(bestPrefix) == 0 {
|
||||||
|
return MatchResult{Status: MatchStatusMissing}
|
||||||
|
}
|
||||||
|
return MatchResult{Status: MatchStatusAmbiguous}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTargets(item vault.Entry) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
targets := make([]string, 0, 1+len(item.Fields))
|
||||||
|
appendTarget := func(raw string) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
targets = append(targets, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTarget(item.URL)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(item.Fields))
|
||||||
|
for key := range item.Fields {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
upper := strings.ToUpper(strings.TrimSpace(key))
|
||||||
|
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
|
||||||
|
appendTarget(item.Fields[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryTargets(entry Entry) []normalizedTarget {
|
||||||
|
values := entry.Targets
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = []string{entry.URL}
|
||||||
|
}
|
||||||
|
targets := make([]normalizedTarget, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
target := normalizeURL(value)
|
||||||
|
if target.host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryMatchesHost(entry Entry, host string) bool {
|
||||||
|
for _, target := range entryTargets(entry) {
|
||||||
|
if target.host == host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryMatchesParentHost(entry Entry, host string) bool {
|
||||||
|
for _, target := range entryTargets(entry) {
|
||||||
|
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
|
||||||
|
bestPrefixLen := -1
|
||||||
|
for _, candidate := range entryTargets(entry) {
|
||||||
|
if candidate.url == target.url {
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
|
||||||
|
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
|
||||||
|
bestPrefixLen = pathLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, bestPrefixLen
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package autofillcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
got := Build(vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Chrome Test",
|
||||||
|
Username: "joe",
|
||||||
|
Password: "secret",
|
||||||
|
URL: "https://10.0.2.2:8443/login",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "No Password",
|
||||||
|
Username: "joe",
|
||||||
|
URL: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "three",
|
||||||
|
Title: "Bare Host",
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
URL: "surveillance.crew.example.invalid",
|
||||||
|
Fields: map[string]string{
|
||||||
|
"AndroidApp1": "androidapp://com.lights.mobile",
|
||||||
|
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, now)
|
||||||
|
|
||||||
|
if len(got.Entries) != 2 {
|
||||||
|
t.Fatalf("entry count = %d, want 2", len(got.Entries))
|
||||||
|
}
|
||||||
|
if got.Entries[0].Host != "10.0.2.2" {
|
||||||
|
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
|
||||||
|
}
|
||||||
|
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
|
||||||
|
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
|
||||||
|
}
|
||||||
|
if len(got.Entries[1].Targets) != 3 {
|
||||||
|
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
|
||||||
|
}
|
||||||
|
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
|
||||||
|
t.Fatalf("updatedAt = %q", got.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAndClear(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "autofill-cache.json")
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
var got File
|
||||||
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
|
||||||
|
t.Fatalf("cache entries = %#v", got.Entries)
|
||||||
|
}
|
||||||
|
if err := Clear(path); err != nil {
|
||||||
|
t.Fatalf("Clear() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("cache path still exists, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Primary Login",
|
||||||
|
Username: "first",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://10.0.2.2:8443/login/",
|
||||||
|
Host: "10.0.2.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "Alt Login",
|
||||||
|
Username: "second",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://10.0.2.2:8443/alt/",
|
||||||
|
Host: "10.0.2.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Match() found no entry")
|
||||||
|
}
|
||||||
|
if got.ID != "two" {
|
||||||
|
t.Fatalf("Match() entry = %q, want two", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Host A",
|
||||||
|
Username: "first",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://surveillance.crew.example.invalid/",
|
||||||
|
Host: "surveillance.crew.example.invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "Host B",
|
||||||
|
Username: "second",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://surveillance.crew.example.invalid/",
|
||||||
|
Host: "surveillance.crew.example.invalid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
|
||||||
|
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Admin Login",
|
||||||
|
Username: "admin",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://example.com/admin",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "Shared Login A",
|
||||||
|
Username: "shared-a",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://shared.example.com",
|
||||||
|
Host: "shared.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "three",
|
||||||
|
Title: "Shared Login B",
|
||||||
|
Username: "shared-b",
|
||||||
|
Password: "secret3",
|
||||||
|
URL: "https://shared.example.com",
|
||||||
|
Host: "shared.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
|
||||||
|
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
|
||||||
|
}
|
||||||
|
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
|
||||||
|
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
|
||||||
|
}
|
||||||
|
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
|
||||||
|
t.Fatalf("Resolve(missing) = %#v, want missing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Generic Login",
|
||||||
|
Username: "generic",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://example.com/",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "Admin Login",
|
||||||
|
Username: "admin",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "https://example.com/admin",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := Match(cache, "https://example.com/admin/login")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Match() found no entry")
|
||||||
|
}
|
||||||
|
if got.ID != "two" {
|
||||||
|
t.Fatalf("Match() entry = %q, want two", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Thunderbird",
|
||||||
|
Username: "mail-user",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "androidapp://org.mozilla.thunderbird/login",
|
||||||
|
Host: "org.mozilla.thunderbird",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Match() found no entry")
|
||||||
|
}
|
||||||
|
if got.ID != "one" {
|
||||||
|
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Thunderbird Primary",
|
||||||
|
Username: "mail-user",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "androidapp://org.mozilla.thunderbird",
|
||||||
|
Host: "org.mozilla.thunderbird",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two",
|
||||||
|
Title: "Thunderbird Secondary",
|
||||||
|
Username: "other-user",
|
||||||
|
Password: "secret2",
|
||||||
|
URL: "androidapp://org.mozilla.thunderbird",
|
||||||
|
Host: "org.mozilla.thunderbird",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
|
||||||
|
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Blink",
|
||||||
|
Username: "blink-user",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://account.blinknetwork.com",
|
||||||
|
Host: "account.blinknetwork.com",
|
||||||
|
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Match() found no entry")
|
||||||
|
}
|
||||||
|
if got.ID != "one" {
|
||||||
|
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := File{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "one",
|
||||||
|
Title: "Blink",
|
||||||
|
Username: "blink-user",
|
||||||
|
Password: "secret1",
|
||||||
|
URL: "https://blinknetwork.com",
|
||||||
|
Host: "blinknetwork.com",
|
||||||
|
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := Match(cache, "https://account.blinknetwork.com")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Match() found no entry")
|
||||||
|
}
|
||||||
|
if got.ID != "one" {
|
||||||
|
t.Fatalf("Match() entry = %q, want one", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package buildapk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultSDKRoot = "/opt/android-sdk"
|
||||||
|
DefaultNDKRoot = "/opt/android-ndk"
|
||||||
|
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
|
||||||
|
DefaultAppID = "org.julianfamily.keepassgo"
|
||||||
|
DefaultAPKOut = "build/keepassgo.apk"
|
||||||
|
DefaultVersion = "0.1.0.1"
|
||||||
|
DefaultLdflags = "-X main.appVersion=dev"
|
||||||
|
DefaultMinSDK = "28"
|
||||||
|
DefaultTargetSDK = "35"
|
||||||
|
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SDKRoot string
|
||||||
|
NDKRoot string
|
||||||
|
JavaHome string
|
||||||
|
AppID string
|
||||||
|
APKOut string
|
||||||
|
Version string
|
||||||
|
Ldflags string
|
||||||
|
MinSDK string
|
||||||
|
TargetSDK string
|
||||||
|
IconPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
SDKRoot: DefaultSDKRoot,
|
||||||
|
NDKRoot: DefaultNDKRoot,
|
||||||
|
JavaHome: DefaultJavaHome,
|
||||||
|
AppID: DefaultAppID,
|
||||||
|
APKOut: DefaultAPKOut,
|
||||||
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
|
MinSDK: DefaultMinSDK,
|
||||||
|
TargetSDK: DefaultTargetSDK,
|
||||||
|
IconPath: DefaultIconPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) GogioArgs() []string {
|
||||||
|
return []string{
|
||||||
|
"-target", "android",
|
||||||
|
"-buildmode", "exe",
|
||||||
|
"-appid", c.AppID,
|
||||||
|
"-ldflags", c.Ldflags,
|
||||||
|
"-o", c.APKOut,
|
||||||
|
"-version", c.Version,
|
||||||
|
"-minsdk", c.MinSDK,
|
||||||
|
"-targetsdk", c.TargetSDK,
|
||||||
|
"-icon", c.IconPath,
|
||||||
|
".",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
|
||||||
|
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
|
||||||
|
}
|
||||||
|
if !isDir(c.SDKRoot) {
|
||||||
|
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
|
||||||
|
}
|
||||||
|
if !isDir(c.NDKRoot) {
|
||||||
|
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
|
||||||
|
}
|
||||||
|
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
|
||||||
|
return fmt.Errorf("Android SDK cmdline-tools are missing")
|
||||||
|
}
|
||||||
|
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
|
||||||
|
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
|
||||||
|
}
|
||||||
|
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
|
||||||
|
return fmt.Errorf("Android build-tools are missing")
|
||||||
|
}
|
||||||
|
if !isFile(c.IconPath) {
|
||||||
|
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExecutable(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Mode()&0o111 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFile(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package buildapk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
want := []string{
|
||||||
|
"-target", "android",
|
||||||
|
"-buildmode", "exe",
|
||||||
|
"-appid", DefaultAppID,
|
||||||
|
"-ldflags", DefaultLdflags,
|
||||||
|
"-o", DefaultAPKOut,
|
||||||
|
"-version", DefaultVersion,
|
||||||
|
"-minsdk", DefaultMinSDK,
|
||||||
|
"-targetsdk", DefaultTargetSDK,
|
||||||
|
"-icon", DefaultIconPath,
|
||||||
|
".",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("GogioArgs() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
sdkRoot := filepath.Join(root, "sdk")
|
||||||
|
ndkRoot := filepath.Join(root, "ndk")
|
||||||
|
javaHome := filepath.Join(root, "java")
|
||||||
|
|
||||||
|
mkdirAll(t, filepath.Join(javaHome, "bin"))
|
||||||
|
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
|
||||||
|
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
|
||||||
|
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
|
||||||
|
mkdirAll(t, ndkRoot)
|
||||||
|
|
||||||
|
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
|
||||||
|
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
SDKRoot: sdkRoot,
|
||||||
|
NDKRoot: ndkRoot,
|
||||||
|
JavaHome: javaHome,
|
||||||
|
AppID: DefaultAppID,
|
||||||
|
APKOut: DefaultAPKOut,
|
||||||
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
|
MinSDK: DefaultMinSDK,
|
||||||
|
TargetSDK: DefaultTargetSDK,
|
||||||
|
IconPath: filepath.Join(root, "icon.png"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := Config{
|
||||||
|
SDKRoot: filepath.Join(root, "missing-sdk"),
|
||||||
|
NDKRoot: filepath.Join(root, "missing-ndk"),
|
||||||
|
JavaHome: filepath.Join(root, "missing-java"),
|
||||||
|
AppID: DefaultAppID,
|
||||||
|
APKOut: DefaultAPKOut,
|
||||||
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
|
MinSDK: DefaultMinSDK,
|
||||||
|
TargetSDK: DefaultTargetSDK,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want missing prerequisite error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkdirAll(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeExecutable(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,10 @@ func (s Service) writer() Writer {
|
|||||||
return systemWriter{}
|
return systemWriter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WriteText(text string) error {
|
||||||
|
return systemWriter{}.WriteText(text)
|
||||||
|
}
|
||||||
|
|
||||||
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
|
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
|
||||||
for _, entry := range model.Entries {
|
for _, entry := range model.Entries {
|
||||||
if entry.ID == entryID {
|
if entry.ID == entryID {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Accessibility Review
|
||||||
|
|
||||||
|
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
||||||
|
|
||||||
|
## What is intentionally supported
|
||||||
|
|
||||||
|
- Keyboard focus is explicit for:
|
||||||
|
- vault search
|
||||||
|
- breadcrumb navigation
|
||||||
|
- entry list selection
|
||||||
|
- detail/editor fields
|
||||||
|
- Focus styling is visible and distinct from the unfocused field treatment.
|
||||||
|
- Common keyboard workflows are covered in-repo by tests for:
|
||||||
|
- tab navigation
|
||||||
|
- list navigation
|
||||||
|
- search focus
|
||||||
|
- new-entry focus transitions
|
||||||
|
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
|
||||||
|
|
||||||
|
## Current screen-reader boundary
|
||||||
|
|
||||||
|
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
|
||||||
|
- KeePassGO therefore treats screen-reader support as:
|
||||||
|
- label-conscious where the toolkit exposes focusable controls
|
||||||
|
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
|
||||||
|
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
|
||||||
|
|
||||||
|
## Current review result
|
||||||
|
|
||||||
|
- Linux:
|
||||||
|
- keyboard/focus behavior is intentionally supported
|
||||||
|
- visible focus states and control naming are present in code
|
||||||
|
- full Orca-style semantic verification is not something this repo can assert automatically today
|
||||||
|
- Windows:
|
||||||
|
- the same keyboard/focus behavior and explicit labels are present in-app
|
||||||
|
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
|
||||||
|
|
||||||
|
## What KeePassGO should continue doing
|
||||||
|
|
||||||
|
- Keep every major workflow operable without a pointer device.
|
||||||
|
- Add explicit labels for any new focusable control.
|
||||||
|
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
|
||||||
|
- Prefer dialogs and panels that keep keyboard focus predictable.
|
||||||
|
|
||||||
|
## What remains toolkit-limited
|
||||||
|
|
||||||
|
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
|
||||||
|
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
|
||||||
@@ -10,6 +10,9 @@ KeePassGO supports the following KDBX security workflows today:
|
|||||||
- preserve the original opened vault's KDBX format version during save
|
- preserve the original opened vault's KDBX format version during save
|
||||||
- preserve the original opened vault's cipher selection during save
|
- preserve the original opened vault's cipher selection during save
|
||||||
- preserve the original opened vault's KDF selection during save
|
- preserve the original opened vault's KDF selection during save
|
||||||
|
- choose the cipher family for new vault creation
|
||||||
|
- choose the KDF family for new vault creation
|
||||||
|
- change the cipher family and KDF family for an existing unlocked session before the next save
|
||||||
|
|
||||||
What "preserve" means:
|
What "preserve" means:
|
||||||
|
|
||||||
@@ -18,11 +21,11 @@ What "preserve" means:
|
|||||||
|
|
||||||
Current explicit limitations:
|
Current explicit limitations:
|
||||||
|
|
||||||
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
|
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
|
||||||
- new vault creation still uses the library default KDBX header settings for freshly created databases
|
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
|
||||||
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
|
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
|
||||||
|
|
||||||
Practical expectation:
|
Practical expectation:
|
||||||
|
|
||||||
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
|
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
|
||||||
- KeePassGO does not yet try to be a full advanced database-settings editor
|
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ module git.julianfamily.org/keepassgo
|
|||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.9.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
|
||||||
@@ -14,8 +17,9 @@ require (
|
|||||||
require (
|
require (
|
||||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||||
gioui.org/cmd v0.9.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
|
||||||
@@ -72,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
|
||||||
@@ -190,6 +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-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
|
||||||
|
|||||||
@@ -37,13 +37,15 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
|||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||||
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||||
gioui.org/cmd v0.9.0 h1:H1F2u3vBAd8TDRvaJd4IbrbbiOPBWc7Z3ZykOYoq/20=
|
|
||||||
gioui.org/cmd v0.9.0/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
|
|
||||||
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=
|
||||||
@@ -128,6 +130,10 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
|
|||||||
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
|
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
|
||||||
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
||||||
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
||||||
|
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
||||||
|
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
@@ -216,6 +222,14 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
|
|||||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||||
|
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||||
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.12.1 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=
|
||||||
@@ -354,6 +368,8 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
@@ -386,6 +402,8 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
|
|||||||
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
|
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
|
||||||
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
||||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||||
|
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
|
||||||
|
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||||
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
||||||
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
||||||
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -32,6 +33,46 @@ type Manager struct {
|
|||||||
remoteVersion webdav.Version
|
remoteVersion webdav.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreparedLocalOpen struct {
|
||||||
|
Model vault.Model
|
||||||
|
Config *vault.KDBXConfig
|
||||||
|
Path string
|
||||||
|
Key vault.MasterKey
|
||||||
|
Encoded []byte
|
||||||
|
VaultRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreparedRemoteOpen struct {
|
||||||
|
Model vault.Model
|
||||||
|
Config *vault.KDBXConfig
|
||||||
|
Client webdav.Client
|
||||||
|
Path string
|
||||||
|
Key vault.MasterKey
|
||||||
|
Encoded []byte
|
||||||
|
VaultRoot string
|
||||||
|
RemoteVersion webdav.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreparedUnlock struct {
|
||||||
|
Model vault.Model
|
||||||
|
Config *vault.KDBXConfig
|
||||||
|
Key vault.MasterKey
|
||||||
|
VaultRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
||||||
|
return vault.DetectSecuritySettings(m.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||||
|
config, err := vault.ApplySecuritySettings(configOrCurrent(m.config, nil), settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configure security settings: %w", err)
|
||||||
|
}
|
||||||
|
m.config = config
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||||
root := detectSingleVaultRoot(model)
|
root := detectSingleVaultRoot(model)
|
||||||
model = normalizeUnderRoot(model, root)
|
model = normalizeUnderRoot(model, root)
|
||||||
@@ -52,6 +93,10 @@ func (m *Manager) HasVault() bool {
|
|||||||
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) EncodedBytes() []byte {
|
||||||
|
return append([]byte(nil), m.encoded...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) IsLocked() bool {
|
func (m *Manager) IsLocked() bool {
|
||||||
return m.locked
|
return m.locked
|
||||||
}
|
}
|
||||||
@@ -61,23 +106,11 @@ func (m *Manager) IsRemote() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||||
content, err := os.ReadFile(path)
|
prepared, err := PrepareLocalOpen(path, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read %s: %w", path, err)
|
return err
|
||||||
}
|
}
|
||||||
|
m.ApplyPreparedLocalOpen(prepared)
|
||||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.model = model
|
|
||||||
m.config = config
|
|
||||||
m.path = path
|
|
||||||
m.key = key
|
|
||||||
m.vaultRoot = detectSingleVaultRoot(model)
|
|
||||||
m.encoded = content
|
|
||||||
m.locked = false
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,25 +127,11 @@ func (m *Manager) Save() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
|
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
|
||||||
content, version, err := client.Open(path)
|
prepared, err := PrepareRemoteOpen(client, path, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open remote %s: %w", path, err)
|
return err
|
||||||
}
|
}
|
||||||
|
m.ApplyPreparedRemoteOpen(prepared)
|
||||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decode remote %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.model = model
|
|
||||||
m.config = config
|
|
||||||
m.key = key
|
|
||||||
m.vaultRoot = detectSingleVaultRoot(model)
|
|
||||||
m.encoded = content
|
|
||||||
m.locked = false
|
|
||||||
m.remoteClient = &client
|
|
||||||
m.remotePath = path
|
|
||||||
m.remoteVersion = version
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,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 {
|
||||||
@@ -252,19 +283,101 @@ func (m *Manager) Lock() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Unlock(key vault.MasterKey) error {
|
func (m *Manager) Unlock(key vault.MasterKey) error {
|
||||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
|
prepared, err := PrepareUnlock(m.encoded, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unlock vault: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
m.ApplyPreparedUnlock(prepared)
|
||||||
m.model = model
|
|
||||||
m.config = config
|
|
||||||
m.key = key
|
|
||||||
m.vaultRoot = detectSingleVaultRoot(model)
|
|
||||||
m.locked = false
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||||
|
if err != nil {
|
||||||
|
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return PreparedLocalOpen{
|
||||||
|
Model: model,
|
||||||
|
Config: config,
|
||||||
|
Path: path,
|
||||||
|
Key: key,
|
||||||
|
Encoded: content,
|
||||||
|
VaultRoot: detectSingleVaultRoot(model),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
|
||||||
|
content, version, err := client.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
|
||||||
|
}
|
||||||
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
|
||||||
|
if err != nil {
|
||||||
|
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return PreparedRemoteOpen{
|
||||||
|
Model: model,
|
||||||
|
Config: config,
|
||||||
|
Client: client,
|
||||||
|
Path: path,
|
||||||
|
Key: key,
|
||||||
|
Encoded: content,
|
||||||
|
VaultRoot: detectSingleVaultRoot(model),
|
||||||
|
RemoteVersion: version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
|
||||||
|
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
|
||||||
|
if err != nil {
|
||||||
|
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
||||||
|
}
|
||||||
|
return PreparedUnlock{
|
||||||
|
Model: model,
|
||||||
|
Config: config,
|
||||||
|
Key: key,
|
||||||
|
VaultRoot: detectSingleVaultRoot(model),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
||||||
|
m.model = prepared.Model
|
||||||
|
m.config = prepared.Config
|
||||||
|
m.path = prepared.Path
|
||||||
|
m.key = prepared.Key
|
||||||
|
m.vaultRoot = prepared.VaultRoot
|
||||||
|
m.encoded = prepared.Encoded
|
||||||
|
m.locked = false
|
||||||
|
m.remoteClient = nil
|
||||||
|
m.remotePath = ""
|
||||||
|
m.remoteVersion = webdav.Version{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||||
|
m.model = prepared.Model
|
||||||
|
m.config = prepared.Config
|
||||||
|
m.key = prepared.Key
|
||||||
|
m.vaultRoot = prepared.VaultRoot
|
||||||
|
m.encoded = prepared.Encoded
|
||||||
|
m.locked = false
|
||||||
|
m.remoteClient = &prepared.Client
|
||||||
|
m.remotePath = prepared.Path
|
||||||
|
m.remoteVersion = prepared.RemoteVersion
|
||||||
|
m.path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||||
|
m.model = prepared.Model
|
||||||
|
m.config = prepared.Config
|
||||||
|
m.key = prepared.Key
|
||||||
|
m.vaultRoot = prepared.VaultRoot
|
||||||
|
m.locked = false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||||
var (
|
var (
|
||||||
model vault.Model
|
model vault.Model
|
||||||
@@ -808,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 {
|
||||||
|
|||||||
@@ -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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -741,6 +741,35 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigureSecurityAppliesToCreatedVaultAndPersists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.ConfigureSecurity(vault.SecuritySettings{
|
||||||
|
Cipher: vault.CipherAES256,
|
||||||
|
KDF: vault.KDFAES,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ConfigureSecurity() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "secure.kdbx")
|
||||||
|
if err := sess.SaveAs(path); err != nil {
|
||||||
|
t.Fatalf("SaveAs() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened Manager
|
||||||
|
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
got := reopened.SecuritySettings()
|
||||||
|
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
|
||||||
|
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
|
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -749,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -812,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"},
|
||||||
})
|
})
|
||||||
@@ -830,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)
|
||||||
@@ -854,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 {
|
||||||
@@ -876,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -888,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -924,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()
|
||||||
|
|
||||||
@@ -935,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -947,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -992,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1004,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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: debian/testing
|
||||||
|
packages:
|
||||||
|
- clang
|
||||||
|
- cmake
|
||||||
|
- curl
|
||||||
|
- autoconf
|
||||||
|
- libxml2-dev
|
||||||
|
- libssl-dev
|
||||||
|
- libz-dev
|
||||||
|
- llvm-dev # for cctools
|
||||||
|
- uuid-dev ## for cctools
|
||||||
|
- libplist-utils # for gogio
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
- https://git.sr.ht/~eliasnaur/applesdks
|
||||||
|
- https://git.sr.ht/~eliasnaur/giouiorg
|
||||||
|
- https://github.com/tpoechtrager/cctools-port.git
|
||||||
|
- https://github.com/tpoechtrager/apple-libtapi.git
|
||||||
|
- https://github.com/mackyle/xar.git
|
||||||
|
environment:
|
||||||
|
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
|
||||||
|
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- prepare_toolchain: |
|
||||||
|
mkdir -p $APPLE_TOOLCHAIN_ROOT
|
||||||
|
cd $APPLE_TOOLCHAIN_ROOT
|
||||||
|
tar xJf /home/build/applesdks/applesdks.tar.xz
|
||||||
|
mkdir bin tools
|
||||||
|
cd bin
|
||||||
|
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
|
||||||
|
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
|
||||||
|
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
|
||||||
|
ln -s ../tools/appletoolchain xcrun
|
||||||
|
ln -s /usr/bin/plistutil plutil
|
||||||
|
cd ../tools
|
||||||
|
ln -s appletoolchain clang-ios
|
||||||
|
ln -s appletoolchain clang-macos
|
||||||
|
- install_appletoolchain: |
|
||||||
|
cd giouiorg
|
||||||
|
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
|
||||||
|
- build_xar: |
|
||||||
|
cd xar/xar
|
||||||
|
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
|
||||||
|
make
|
||||||
|
sudo make install
|
||||||
|
- build_libtapi: |
|
||||||
|
cd apple-libtapi
|
||||||
|
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
|
||||||
|
./install.sh
|
||||||
|
- build_cctools: |
|
||||||
|
cd cctools-port/cctools
|
||||||
|
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
|
||||||
|
make install
|
||||||
|
- install_gogio: |
|
||||||
|
cd gio-cmd
|
||||||
|
go install ./gogio
|
||||||
|
- test_ios_gogio: |
|
||||||
|
mkdir tmp
|
||||||
|
cd tmp
|
||||||
|
go mod init example.com
|
||||||
|
go get -d gioui.org/example/kitchen
|
||||||
|
export PATH=/home/build/appletools/bin:$PATH
|
||||||
|
gogio -target ios -o app.app gioui.org/example/kitchen
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: freebsd/13.x
|
||||||
|
packages:
|
||||||
|
- libX11
|
||||||
|
- libxkbcommon
|
||||||
|
- libXcursor
|
||||||
|
- libXfixes
|
||||||
|
- vulkan-headers
|
||||||
|
- wayland
|
||||||
|
- mesa-libs
|
||||||
|
- xorg-vfbserver
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- test_cmd: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: debian/bookworm
|
||||||
|
packages:
|
||||||
|
- curl
|
||||||
|
- pkg-config
|
||||||
|
- libwayland-dev
|
||||||
|
- libx11-dev
|
||||||
|
- libx11-xcb-dev
|
||||||
|
- libxkbcommon-dev
|
||||||
|
- libxkbcommon-x11-dev
|
||||||
|
- libgles2-mesa-dev
|
||||||
|
- libegl1-mesa-dev
|
||||||
|
- libffi-dev
|
||||||
|
- libvulkan-dev
|
||||||
|
- libxcursor-dev
|
||||||
|
- libxrandr-dev
|
||||||
|
- libxinerama-dev
|
||||||
|
- libxi-dev
|
||||||
|
- libxxf86vm-dev
|
||||||
|
- mesa-vulkan-drivers
|
||||||
|
- wine
|
||||||
|
- xvfb
|
||||||
|
- xdotool
|
||||||
|
- scrot
|
||||||
|
- sway
|
||||||
|
- grim
|
||||||
|
- wine
|
||||||
|
- unzip
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
|
||||||
|
ANDROID_SDK_ROOT: /home/build/android
|
||||||
|
android_sdk_tools_zip: sdk-tools-linux-3859397.zip
|
||||||
|
android_ndk_zip: android-ndk-r20-linux-x86_64.zip
|
||||||
|
github_mirror: git@github.com:gioui/gio-cmd
|
||||||
|
secrets:
|
||||||
|
- fdc570bf-87f4-4528-8aee-4d1711b1c86f
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- check_gofmt: |
|
||||||
|
cd gio-cmd
|
||||||
|
test -z "$(gofmt -s -l .)"
|
||||||
|
- check_sign_off: |
|
||||||
|
set +x -e
|
||||||
|
cd gio-cmd
|
||||||
|
for hash in $(git log -n 20 --format="%H"); do
|
||||||
|
message=$(git log -1 --format=%B $hash)
|
||||||
|
if [[ ! "$message" =~ "Signed-off-by: " ]]; then
|
||||||
|
echo "Missing 'Signed-off-by' in commit $hash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
- mirror: |
|
||||||
|
# mirror to github
|
||||||
|
ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
|
||||||
|
- install_chrome: |
|
||||||
|
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||||
|
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||||
|
sudo apt-get -qq update
|
||||||
|
sudo apt-get -qq install -y google-chrome-stable
|
||||||
|
- test: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
- install_jdk8: |
|
||||||
|
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
|
||||||
|
sudo apt-get -qq install -y -f ./jdk.deb
|
||||||
|
- install_android: |
|
||||||
|
mkdir android
|
||||||
|
cd android
|
||||||
|
curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
|
||||||
|
unzip -q sdk-tools.zip
|
||||||
|
rm sdk-tools.zip
|
||||||
|
curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
|
||||||
|
unzip -q ndk.zip
|
||||||
|
rm ndk.zip
|
||||||
|
mv android-ndk-* ndk-bundle
|
||||||
|
yes|sdkmanager --licenses
|
||||||
|
sdkmanager "platforms;android-31" "build-tools;32.0.0"
|
||||||
|
- install_gogio: |
|
||||||
|
cd gio-cmd
|
||||||
|
go install ./gogio
|
||||||
|
- test_android_gogio: |
|
||||||
|
mkdir tmp
|
||||||
|
cd tmp
|
||||||
|
go mod init example.com
|
||||||
|
go get -d gioui.org/example/kitchen
|
||||||
|
gogio -target android gioui.org/example/kitchen
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: openbsd/latest
|
||||||
|
packages:
|
||||||
|
- libxkbcommon
|
||||||
|
- go
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
cd /home/build/sdk/go/src
|
||||||
|
./make.bash
|
||||||
|
- test_cmd: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
This project is provided under the terms of the UNLICENSE or
|
||||||
|
the MIT license denoted by the following SPDX identifier:
|
||||||
|
|
||||||
|
SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
You may use the project under the terms of either license.
|
||||||
|
|
||||||
|
Both licenses are reproduced below.
|
||||||
|
|
||||||
|
----
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019 The Gio authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
The UNLICENSE
|
||||||
|
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
||||||
|
---
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Gio Tools
|
||||||
|
|
||||||
|
Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
|
||||||
|
|
||||||
|
[](https://builds.sr.ht/~eliasnaur/gio-cmd)
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
|
||||||
|
to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
|
||||||
|
mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
|
||||||
|
[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
|
||||||
|
account is required and you can post without being subscribed.
|
||||||
|
|
||||||
|
See the [contribution guide](https://gioui.org/doc/contribute) for more details.
|
||||||
|
|
||||||
|
An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module gioui.org/cmd
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.8.0
|
||||||
|
github.com/akavel/rsrc v0.10.1
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
|
||||||
|
github.com/chromedp/chromedp v0.5.2
|
||||||
|
golang.org/x/image v0.18.0
|
||||||
|
golang.org/x/sync v0.7.0
|
||||||
|
golang.org/x/text v0.16.0
|
||||||
|
golang.org/x/tools v0.23.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.1 // indirect
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
|
||||||
|
github.com/gobwas/pool v0.2.0 // indirect
|
||||||
|
github.com/gobwas/ws v1.0.2 // indirect
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/mod v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||||
|
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/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
|
||||||
|
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
||||||
|
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
||||||
|
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
||||||
|
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||||
|
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||||
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
|
||||||
|
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
|
||||||
|
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||||
|
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
|
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AndroidTestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
sdkDir string
|
||||||
|
adbPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Start(path string) {
|
||||||
|
d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
|
||||||
|
if d.sdkDir == "" {
|
||||||
|
d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
|
||||||
|
}
|
||||||
|
d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
|
||||||
|
if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
|
||||||
|
d.Skipf("adb not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
devOut := bytes.TrimSpace(d.adb("devices"))
|
||||||
|
devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
|
||||||
|
switch len(devices) {
|
||||||
|
case 0:
|
||||||
|
d.Skipf("no Android devices attached via adb; skipping")
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
d.Skipf("multiple Android devices attached via adb; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the device is attached but asleep, it's probably just charging.
|
||||||
|
// Don't use it; the screen needs to be on and unlocked for the test to
|
||||||
|
// work.
|
||||||
|
if !bytes.Contains(
|
||||||
|
d.adb("shell", "dumpsys", "power"),
|
||||||
|
[]byte(" mWakefulness=Awake"),
|
||||||
|
) {
|
||||||
|
d.Skipf("Android device isn't awake; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
|
||||||
|
d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)
|
||||||
|
|
||||||
|
// Make sure the app isn't installed already, and try to uninstall it
|
||||||
|
// when we finish. Previous failed test runs might have left the app.
|
||||||
|
d.tryUninstall()
|
||||||
|
d.adb("install", apk)
|
||||||
|
d.Cleanup(d.tryUninstall)
|
||||||
|
|
||||||
|
// Force our e2e app to be fullscreen, so that the android system bar at
|
||||||
|
// the top doesn't mess with our screenshots.
|
||||||
|
// TODO(mvdan): is there a way to do this via gio, so that we don't need
|
||||||
|
// to set up a global Android setting via the shell?
|
||||||
|
d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)
|
||||||
|
|
||||||
|
// Make sure the app isn't already running.
|
||||||
|
d.adb("shell", "pm", "clear", appid)
|
||||||
|
|
||||||
|
// Start listening for log messages.
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, d.adbPath,
|
||||||
|
"logcat",
|
||||||
|
"-s", // suppress other logs
|
||||||
|
"-T1", // don't show previous log messages
|
||||||
|
appid+":*", // show all logs from our gio app ID
|
||||||
|
)
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
d.output = output
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the app.
|
||||||
|
d.adb("shell", "monkey", "-p", appid, "1")
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Screenshot() image.Image {
|
||||||
|
out := d.adb("shell", "screencap", "-p")
|
||||||
|
img, err := png.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) tryUninstall() {
|
||||||
|
cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if bytes.Contains(out, []byte("Unknown package")) {
|
||||||
|
// The package is not installed. Don't log anything.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Logf("could not uninstall: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
|
||||||
|
strs := []string{}
|
||||||
|
for _, arg := range args {
|
||||||
|
strs = append(strs, fmt.Sprint(arg))
|
||||||
|
}
|
||||||
|
cmd := exec.Command(d.adbPath, strs...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Click(x, y int) {
|
||||||
|
d.adb("shell", "input", "tap", x, y)
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type buildInfo struct {
|
||||||
|
appID string
|
||||||
|
archs []string
|
||||||
|
ldflags string
|
||||||
|
minsdk int
|
||||||
|
targetsdk int
|
||||||
|
name string
|
||||||
|
pkgDir string
|
||||||
|
pkgPath string
|
||||||
|
iconPath string
|
||||||
|
tags string
|
||||||
|
target string
|
||||||
|
version Semver
|
||||||
|
key string
|
||||||
|
password string
|
||||||
|
notaryAppleID string
|
||||||
|
notaryPassword string
|
||||||
|
notaryTeamID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Semver struct {
|
||||||
|
Major, Minor, Patch int
|
||||||
|
VersionCode uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuildInfo(pkgPath string) (*buildInfo, error) {
|
||||||
|
pkgMetadata, err := getPkgMetadata(pkgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
appID := getAppID(pkgMetadata)
|
||||||
|
appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
|
||||||
|
if *iconPath != "" {
|
||||||
|
appIcon = *iconPath
|
||||||
|
}
|
||||||
|
appName := getPkgName(pkgMetadata)
|
||||||
|
if *name != "" {
|
||||||
|
appName = *name
|
||||||
|
}
|
||||||
|
ver, err := parseSemver(*version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bi := &buildInfo{
|
||||||
|
appID: appID,
|
||||||
|
archs: getArchs(),
|
||||||
|
ldflags: getLdFlags(appID),
|
||||||
|
minsdk: *minsdk,
|
||||||
|
targetsdk: *targetsdk,
|
||||||
|
name: appName,
|
||||||
|
pkgDir: pkgMetadata.Dir,
|
||||||
|
pkgPath: pkgPath,
|
||||||
|
iconPath: appIcon,
|
||||||
|
tags: *extraTags,
|
||||||
|
target: *target,
|
||||||
|
version: ver,
|
||||||
|
key: *signKey,
|
||||||
|
password: *signPass,
|
||||||
|
notaryAppleID: *notaryID,
|
||||||
|
notaryPassword: *notaryPass,
|
||||||
|
notaryTeamID: *notaryTeamID,
|
||||||
|
}
|
||||||
|
return bi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UppercaseName returns a string with its first rune in uppercase.
|
||||||
|
func UppercaseName(name string) string {
|
||||||
|
ch, w := utf8.DecodeRuneInString(name)
|
||||||
|
return string(unicode.ToUpper(ch)) + name[w:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Semver) String() string {
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSemver(v string) (Semver, error) {
|
||||||
|
var sv Semver
|
||||||
|
_, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode)
|
||||||
|
if err != nil {
|
||||||
|
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||||
|
}
|
||||||
|
if sv.String() != v {
|
||||||
|
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||||
|
}
|
||||||
|
return sv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArchs() []string {
|
||||||
|
if *archNames != "" {
|
||||||
|
return strings.Split(*archNames, ",")
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "js":
|
||||||
|
return []string{"wasm"}
|
||||||
|
case "ios", "tvos":
|
||||||
|
// Only 64-bit support.
|
||||||
|
return []string{"arm64", "amd64"}
|
||||||
|
case "android":
|
||||||
|
return []string{"arm", "arm64", "386", "amd64"}
|
||||||
|
case "windows":
|
||||||
|
goarch := os.Getenv("GOARCH")
|
||||||
|
if goarch == "" {
|
||||||
|
goarch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
return []string{goarch}
|
||||||
|
case "macos":
|
||||||
|
return []string{"arm64", "amd64"}
|
||||||
|
default:
|
||||||
|
// TODO: Add flag tests.
|
||||||
|
panic("The target value has already been validated, this will never execute.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLdFlags(appID string) string {
|
||||||
|
var ldflags []string
|
||||||
|
if extra := *extraLdflags; extra != "" {
|
||||||
|
ldflags = append(ldflags, strings.Split(extra, " ")...)
|
||||||
|
}
|
||||||
|
// Pass appID along, to be used for logging on platforms like Android.
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID))
|
||||||
|
// Support earlier Gio versions that had a separate app id recorded.
|
||||||
|
// TODO: delete this in the future.
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
|
||||||
|
// Pass along all remaining arguments to the app.
|
||||||
|
if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
|
||||||
|
}
|
||||||
|
if m := *linkMode; m != "" {
|
||||||
|
ldflags = append(ldflags, "-linkmode="+m)
|
||||||
|
}
|
||||||
|
return strings.Join(ldflags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageMetadata struct {
|
||||||
|
PkgPath string
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
|
||||||
|
pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &packageMetadata{
|
||||||
|
PkgPath: pkgImportPath,
|
||||||
|
Dir: pkgDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppID(pkgMetadata *packageMetadata) string {
|
||||||
|
if *appID != "" {
|
||||||
|
return *appID
|
||||||
|
}
|
||||||
|
elems := strings.Split(pkgMetadata.PkgPath, "/")
|
||||||
|
domain := strings.Split(elems[0], ".")
|
||||||
|
name := ""
|
||||||
|
if len(elems) > 1 {
|
||||||
|
name = "." + elems[len(elems)-1]
|
||||||
|
}
|
||||||
|
if len(elems) < 2 && len(domain) < 2 {
|
||||||
|
name = "." + domain[0]
|
||||||
|
domain[0] = "localhost"
|
||||||
|
} else {
|
||||||
|
for i := 0; i < len(domain)/2; i++ {
|
||||||
|
opp := len(domain) - 1 - i
|
||||||
|
domain[i], domain[opp] = domain[opp], domain[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgDomain := strings.Join(domain, ".")
|
||||||
|
appid := []rune(pkgDomain + name)
|
||||||
|
|
||||||
|
// a Java-language-style package name may contain upper- and lower-case
|
||||||
|
// letters and underscores with individual parts separated by '.'.
|
||||||
|
// https://developer.android.com/guide/topics/manifest/manifest-element
|
||||||
|
for i, c := range appid {
|
||||||
|
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
|
||||||
|
c == '_' || c == '.') {
|
||||||
|
appid[i] = '_'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(appid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPkgName(pkgMetadata *packageMetadata) string {
|
||||||
|
return path.Base(pkgMetadata.PkgPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
type expval struct {
|
||||||
|
in, out string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []expval{
|
||||||
|
{"example", "localhost.example"},
|
||||||
|
{"example.com", "com.example"},
|
||||||
|
{"www.example.com", "com.example.www"},
|
||||||
|
{"examplecom/app", "examplecom.app"},
|
||||||
|
{"example.com/app", "com.example.app"},
|
||||||
|
{"www.example.com/app", "com.example.www.app"},
|
||||||
|
{"www.en.example.com/app", "com.example.en.www.app"},
|
||||||
|
{"example.com/dir/app", "com.example.app"},
|
||||||
|
{"example.com/dir.ext/app", "com.example.app"},
|
||||||
|
{"example.com/dir/app.ext", "com.example.app.ext"},
|
||||||
|
{"example-com.net/dir/app", "net.example_com.app"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
got := getAppID(&packageMetadata{PkgPath: test.in})
|
||||||
|
if exp := test.out; got != exp {
|
||||||
|
t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
/*
|
||||||
|
The gogio tool builds and packages Gio programs for Android, iOS/tvOS
|
||||||
|
and WebAssembly.
|
||||||
|
|
||||||
|
Run gogio with no arguments for instructions, or see the examples at
|
||||||
|
https://gioui.org.
|
||||||
|
*/
|
||||||
|
package main
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var raceEnabled = false
|
||||||
|
|
||||||
|
var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")
|
||||||
|
|
||||||
|
const appid = "localhost.gogio.endtoend"
|
||||||
|
|
||||||
|
// TestDriver is implemented by each of the platforms we can run end-to-end
|
||||||
|
// tests on. None of its methods return any errors, as the errors are directly
|
||||||
|
// reported to testing.T via methods like Fatal.
|
||||||
|
type TestDriver interface {
|
||||||
|
initBase(t *testing.T, width, height int)
|
||||||
|
|
||||||
|
// Start opens the Gio app found at path. The driver should attempt to
|
||||||
|
// run the app with the base driver's width and height, and the
|
||||||
|
// platform's background should be white.
|
||||||
|
//
|
||||||
|
// When the function returns, the gio app must be ready to use on the
|
||||||
|
// platform, with its initial frame fully drawn.
|
||||||
|
Start(path string)
|
||||||
|
|
||||||
|
// Screenshot takes a screenshot of the Gio app on the platform.
|
||||||
|
Screenshot() image.Image
|
||||||
|
|
||||||
|
// Click performs a pointer click at the specified coordinates,
|
||||||
|
// including both press and release. It returns when the next frame is
|
||||||
|
// fully drawn.
|
||||||
|
Click(x, y int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type driverBase struct {
|
||||||
|
*testing.T
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
|
||||||
|
output io.Reader
|
||||||
|
frameNotifs chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) initBase(t *testing.T, width, height int) {
|
||||||
|
d.T = t
|
||||||
|
d.width, d.height = width, height
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skipf("end-to-end tests tend to be slow")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal"
|
||||||
|
testdataWithRelativePkgPath = "internal/normal/testdata.go"
|
||||||
|
customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go"
|
||||||
|
)
|
||||||
|
// Keep this list local, to not reuse TestDriver objects.
|
||||||
|
subtests := []struct {
|
||||||
|
name string
|
||||||
|
driver TestDriver
|
||||||
|
pkgPath string
|
||||||
|
skipGeese string
|
||||||
|
}{
|
||||||
|
{"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""},
|
||||||
|
{"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"},
|
||||||
|
// Doesn't work on the builders.
|
||||||
|
//{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
|
||||||
|
{"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subtest := range subtests {
|
||||||
|
t.Run(subtest.name, func(t *testing.T) {
|
||||||
|
subtest := subtest // copy the changing loop variable
|
||||||
|
if strings.Contains(subtest.skipGeese, runtime.GOOS) {
|
||||||
|
t.Skipf("not supported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
t.Parallel()
|
||||||
|
runEndToEndTest(t, subtest.driver, subtest.pkgPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
|
||||||
|
size := image.Point{X: 800, Y: 600}
|
||||||
|
driver.initBase(t, size.X, size.Y)
|
||||||
|
|
||||||
|
t.Log("starting driver and gio app")
|
||||||
|
driver.Start(pkgPath)
|
||||||
|
|
||||||
|
beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
|
||||||
|
white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
|
||||||
|
black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
|
||||||
|
red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
// These are the four colors at the beginning.
|
||||||
|
t.Log("taking initial screenshot")
|
||||||
|
withRetries(t, 4*time.Second, func() error {
|
||||||
|
img := driver.Screenshot()
|
||||||
|
size = img.Bounds().Size() // override the default size
|
||||||
|
return checkImageCorners(img, beef, white, black, gray)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
|
||||||
|
// almost works to automate clicks, but the button presses end up in the
|
||||||
|
// wrong coordinates.
|
||||||
|
if _, ok := driver.(*WaylandTestDriver); ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the first and last sections to turn them red.
|
||||||
|
t.Log("clicking twice and taking another screenshot")
|
||||||
|
driver.Click(1*(size.X/4), 1*(size.Y/4))
|
||||||
|
driver.Click(3*(size.X/4), 3*(size.Y/4))
|
||||||
|
withRetries(t, 4*time.Second, func() error {
|
||||||
|
img := driver.Screenshot()
|
||||||
|
return checkImageCorners(img, red, white, black, red)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
|
||||||
|
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
|
||||||
|
// such, timeout should generally be in the order of seconds.
|
||||||
|
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
timeoutTimer := time.NewTimer(timeout)
|
||||||
|
defer timeoutTimer.Stop()
|
||||||
|
backoff := 100 * time.Millisecond
|
||||||
|
|
||||||
|
tries := 0
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
if lastErr = fn(); lastErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tries++
|
||||||
|
t.Logf("retrying after %s", backoff)
|
||||||
|
|
||||||
|
// Use a timer instead of a sleep, so that the timeout can stop
|
||||||
|
// the backoff early. Don't reuse this timer, since we're not in
|
||||||
|
// a hot loop, and we don't want tricky code.
|
||||||
|
backoffTimer := time.NewTimer(backoff)
|
||||||
|
defer backoffTimer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timeoutTimer.C:
|
||||||
|
t.Errorf("last error: %v", lastErr)
|
||||||
|
t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
|
||||||
|
case <-backoffTimer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep doubling it until a maximum. With the start at 100ms,
|
||||||
|
// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
|
||||||
|
backoff *= 2
|
||||||
|
if max := 2 * time.Second; backoff > max {
|
||||||
|
backoff = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type colorMismatch struct {
|
||||||
|
x, y int
|
||||||
|
wantRGB, gotRGB [3]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m colorMismatch) String() string {
|
||||||
|
return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
|
||||||
|
m.x, m.y,
|
||||||
|
m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
|
||||||
|
m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
|
||||||
|
// The colors are split in four rectangular sections. Check the corners
|
||||||
|
// of each of the sections. We check the corners left to right, top to
|
||||||
|
// bottom, like when reading left-to-right text.
|
||||||
|
|
||||||
|
size := img.Bounds().Size()
|
||||||
|
var mismatches []colorMismatch
|
||||||
|
|
||||||
|
checkColor := func(x, y int, want color.Color) {
|
||||||
|
r, g, b, _ := want.RGBA()
|
||||||
|
got := img.At(x, y)
|
||||||
|
r_, g_, b_, _ := got.RGBA()
|
||||||
|
if r_ != r || g_ != g || b_ != b {
|
||||||
|
mismatches = append(mismatches, colorMismatch{
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
wantRGB: [3]uint32{r, g, b},
|
||||||
|
gotRGB: [3]uint32{r_, g_, b_},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
minX, minY := 5, 5
|
||||||
|
maxX, maxY := (size.X/2)-5, (size.Y/2)-5
|
||||||
|
checkColor(minX, minY, topLeft)
|
||||||
|
checkColor(maxX, minY, topLeft)
|
||||||
|
checkColor(minX, maxY, topLeft)
|
||||||
|
checkColor(maxX, maxY, topLeft)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := (size.X/2)+5, 5
|
||||||
|
maxX, maxY := size.X-5, (size.Y/2)-5
|
||||||
|
checkColor(minX, minY, topRight)
|
||||||
|
checkColor(maxX, minY, topRight)
|
||||||
|
checkColor(minX, maxY, topRight)
|
||||||
|
checkColor(maxX, maxY, topRight)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := 5, (size.Y/2)+5
|
||||||
|
maxX, maxY := (size.X/2)-5, size.Y-5
|
||||||
|
checkColor(minX, minY, botLeft)
|
||||||
|
checkColor(maxX, minY, botLeft)
|
||||||
|
checkColor(minX, maxY, botLeft)
|
||||||
|
checkColor(maxX, maxY, botLeft)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := (size.X/2)+5, (size.Y/2)+5
|
||||||
|
maxX, maxY := size.X-5, size.Y-5
|
||||||
|
checkColor(minX, minY, botRight)
|
||||||
|
checkColor(maxX, minY, botRight)
|
||||||
|
checkColor(minX, maxY, botRight)
|
||||||
|
checkColor(maxX, maxY, botRight)
|
||||||
|
}
|
||||||
|
if n := len(mismatches); n > 0 {
|
||||||
|
b := new(strings.Builder)
|
||||||
|
fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
|
||||||
|
for _, m := range mismatches {
|
||||||
|
fmt.Fprintf(b, "%s\n", m)
|
||||||
|
}
|
||||||
|
return errors.New(b.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) waitForFrame() {
|
||||||
|
d.Helper()
|
||||||
|
|
||||||
|
if d.frameNotifs == nil {
|
||||||
|
// Start the goroutine that reads output lines and notifies of
|
||||||
|
// new frames via frameNotifs. The test doesn't wait for this
|
||||||
|
// goroutine to finish; it will naturally end when the output
|
||||||
|
// reader reaches an error like EOF.
|
||||||
|
d.frameNotifs = make(chan bool, 1)
|
||||||
|
if d.output == nil {
|
||||||
|
d.Fatal("need an output reader to be notified of frames")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(d.output)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
d.Log(line)
|
||||||
|
if strings.Contains(line, "gio frame ready") {
|
||||||
|
d.frameNotifs <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Since we're only interested in the output while the
|
||||||
|
// app runs, and we don't know when it finishes here,
|
||||||
|
// ignore "already closed" pipe errors.
|
||||||
|
if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||||
|
d.Errorf("reading app output: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately, there isn't a way to select on a test failing, since
|
||||||
|
// testing.T doesn't have anything like a context or a "done" channel.
|
||||||
|
//
|
||||||
|
// We can't let selects block forever, since the default -test.timeout
|
||||||
|
// is ten minutes - far too long for tests that take seconds.
|
||||||
|
//
|
||||||
|
// For now, a static short timeout is better than nothing. 5s is plenty
|
||||||
|
// for our simple test app to render on any device.
|
||||||
|
select {
|
||||||
|
case <-d.frameNotifs:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
d.Fatalf("timed out waiting for a frame to be ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) needPrograms(names ...string) {
|
||||||
|
d.Helper()
|
||||||
|
for _, name := range names {
|
||||||
|
if _, err := exec.LookPath(name); err != nil {
|
||||||
|
d.Skipf("%s needed to run", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) tempDir(name string) string {
|
||||||
|
d.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", name)
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) gogio(args ...string) {
|
||||||
|
d.Helper()
|
||||||
|
prog, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(prog, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Fatalf("gogio error: %s:\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
gogio -target <target> [flags] <package> [run arguments]
|
||||||
|
|
||||||
|
The gogio tool builds and packages Gio programs for platforms where additional
|
||||||
|
metadata or support files are required.
|
||||||
|
|
||||||
|
The package argument specifies an import path or a single Go source file to
|
||||||
|
package. Any run arguments are appended to os.Args at runtime.
|
||||||
|
|
||||||
|
Compiled Java class files from jar files in the package directory are
|
||||||
|
included in Android builds.
|
||||||
|
|
||||||
|
The mandatory -target flag selects the target platform: ios or android for the
|
||||||
|
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
|
||||||
|
MacOS and windows for Windows.
|
||||||
|
|
||||||
|
The -arch flag specifies a comma separated list of GOARCHs to include. The
|
||||||
|
default is all supported architectures.
|
||||||
|
|
||||||
|
The -o flag specifies an output file or directory, depending on the target.
|
||||||
|
|
||||||
|
The -buildmode flag selects the build mode. Two build modes are available, exe
|
||||||
|
and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
|
||||||
|
for Android or a directory with the WebAssembly module and support files for
|
||||||
|
a browser.
|
||||||
|
|
||||||
|
The -ldflags and -tags flags pass extra linker flags and tags to the go tool.
|
||||||
|
|
||||||
|
As a special case for iOS or tvOS, specifying a path that ends with ".app"
|
||||||
|
will output an app directory suitable for a simulator.
|
||||||
|
|
||||||
|
The other buildmode is archive, which will output an .aar library for Android
|
||||||
|
or a .framework for iOS and tvOS.
|
||||||
|
|
||||||
|
The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
|
||||||
|
If left unspecified, the appicon.png file from the main package is used
|
||||||
|
(if it exists).
|
||||||
|
|
||||||
|
The -appid flag specifies the package name for Android or the bundle id for
|
||||||
|
iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
|
||||||
|
tool can use it.
|
||||||
|
|
||||||
|
The -version flag specifies the integer version code for Android and the last
|
||||||
|
component of the 1.0.X version for iOS and tvOS.
|
||||||
|
|
||||||
|
For Android builds the -minsdk flag specify the minimum SDK level. For example,
|
||||||
|
use -minsdk 22 to target Android 5.1 (Lollipop) and later.
|
||||||
|
|
||||||
|
For Windows builds the -minsdk flag specify the minimum OS version. For example,
|
||||||
|
use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.
|
||||||
|
|
||||||
|
For iOS builds the -minsdk flag specify the minimum iOS version. For example,
|
||||||
|
use -mindk 15 to target iOS 15.0 and later.
|
||||||
|
|
||||||
|
For Android builds the -targetsdk flag specify the target SDK level. For example,
|
||||||
|
use -targetsdk 33 to target Android 13 (Tiramisu) and later.
|
||||||
|
|
||||||
|
The -work flag prints the path to the working directory and suppress
|
||||||
|
its deletion.
|
||||||
|
|
||||||
|
The -x flag will print all the external commands executed by the gogio tool.
|
||||||
|
|
||||||
|
The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
|
||||||
|
or specifies the name of key on Keychain to sign MacOS app.
|
||||||
|
|
||||||
|
The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
|
||||||
|
|
||||||
|
The -notaryid flag specifies the Apple ID to use for notarization of MacOS app.
|
||||||
|
|
||||||
|
The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not
|
||||||
|
provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397
|
||||||
|
for details. If not provided, the password will be prompted.
|
||||||
|
|
||||||
|
The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if
|
||||||
|
-notaryid is not provided.
|
||||||
|
`
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
// This program demonstrates the use of a custom OpenGL ES context with
|
||||||
|
// app.Window.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/gpu"
|
||||||
|
"gioui.org/io/event"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo linux pkg-config: egl wayland-egl
|
||||||
|
#cgo freebsd openbsd CFLAGS: -I/usr/local/include
|
||||||
|
#cgo openbsd CFLAGS: -I/usr/X11R6/include
|
||||||
|
#cgo freebsd LDFLAGS: -L/usr/local/lib
|
||||||
|
#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
|
||||||
|
#cgo freebsd openbsd LDFLAGS: -lwayland-egl
|
||||||
|
#cgo CFLAGS: -DEGL_NO_X11
|
||||||
|
#cgo LDFLAGS: -lEGL -lGLESv2
|
||||||
|
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <wayland-client.h>
|
||||||
|
#include <wayland-egl.h>
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
#define EGL_EGLEXT_PROTOTYPES
|
||||||
|
#include <EGL/eglext.h>
|
||||||
|
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
func getDisplay(ve app.ViewEvent) C.EGLDisplay {
|
||||||
|
switch ve := ve.(type) {
|
||||||
|
case app.X11ViewEvent:
|
||||||
|
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||||
|
case app.WaylandViewEvent:
|
||||||
|
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||||
|
}
|
||||||
|
panic("no display available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
|
||||||
|
switch e := e.(type) {
|
||||||
|
case app.X11ViewEvent:
|
||||||
|
return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
|
||||||
|
case app.WaylandViewEvent:
|
||||||
|
eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
|
||||||
|
return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
|
||||||
|
C.wl_egl_window_destroy(eglWin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("no native view available")
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
C = layout.Context
|
||||||
|
D = layout.Dimensions
|
||||||
|
)
|
||||||
|
|
||||||
|
type notifyFrame int
|
||||||
|
|
||||||
|
const (
|
||||||
|
notifyNone notifyFrame = iota
|
||||||
|
notifyInvalidate
|
||||||
|
notifyPrint
|
||||||
|
)
|
||||||
|
|
||||||
|
// notify keeps track of whether we want to print to stdout to notify the user
|
||||||
|
// when a frame is ready. Initially we want to notify about the first frame.
|
||||||
|
var notify = notifyInvalidate
|
||||||
|
|
||||||
|
type eglContext struct {
|
||||||
|
disp C.EGLDisplay
|
||||||
|
ctx C.EGLContext
|
||||||
|
surf C.EGLSurface
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
// Set CustomRenderer so we can provide our own rendering context.
|
||||||
|
w := new(app.Window)
|
||||||
|
w.Option(app.CustomRenderer(true))
|
||||||
|
if err := loop(w); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loop(w *app.Window) error {
|
||||||
|
var ops op.Ops
|
||||||
|
var (
|
||||||
|
ctx *eglContext
|
||||||
|
gioCtx gpu.GPU
|
||||||
|
ve app.ViewEvent
|
||||||
|
init bool
|
||||||
|
size image.Point
|
||||||
|
)
|
||||||
|
|
||||||
|
recreateContext := func() {
|
||||||
|
w.Run(func() {
|
||||||
|
if gioCtx != nil {
|
||||||
|
gioCtx.Release()
|
||||||
|
gioCtx = nil
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
C.eglMakeCurrent(ctx.disp, nil, nil, nil)
|
||||||
|
ctx.Release()
|
||||||
|
ctx = nil
|
||||||
|
}
|
||||||
|
c, err := createContext(ve, size)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx = c
|
||||||
|
})
|
||||||
|
if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
|
||||||
|
err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
glGetString := func(e C.GLenum) string {
|
||||||
|
return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
|
||||||
|
}
|
||||||
|
fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
|
||||||
|
var err error
|
||||||
|
gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||||
|
}
|
||||||
|
topRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||||
|
}
|
||||||
|
botLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||||
|
}
|
||||||
|
botRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||||
|
}
|
||||||
|
|
||||||
|
// eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
|
||||||
|
runtime.LockOSThread()
|
||||||
|
for {
|
||||||
|
switch e := w.Event().(type) {
|
||||||
|
case app.ViewEvent:
|
||||||
|
ve = e
|
||||||
|
init = true
|
||||||
|
if size != (image.Point{}) {
|
||||||
|
recreateContext()
|
||||||
|
}
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return e.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
if init && size != e.Size {
|
||||||
|
size = e.Size
|
||||||
|
recreateContext()
|
||||||
|
}
|
||||||
|
if gioCtx == nil || !init {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Build ops.
|
||||||
|
gtx := app.NewContext(&ops, e)
|
||||||
|
|
||||||
|
// Clear background to white, even on embedded platforms such as webassembly.
|
||||||
|
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||||
|
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r1c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||||
|
// r1c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r2c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||||
|
// r2c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
gtx.Execute(op.InvalidateCmd{})
|
||||||
|
log.Println("frame")
|
||||||
|
|
||||||
|
// Trigger window resize detection in ANGLE.
|
||||||
|
C.eglWaitClient()
|
||||||
|
// Draw custom OpenGL content.
|
||||||
|
drawGL()
|
||||||
|
|
||||||
|
// Render drawing ops.
|
||||||
|
if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
|
||||||
|
log.Fatal(fmt.Errorf("render failed: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process non-drawing ops.
|
||||||
|
e.Frame(gtx.Ops)
|
||||||
|
switch notify {
|
||||||
|
case notifyInvalidate:
|
||||||
|
notify = notifyPrint
|
||||||
|
w.Invalidate()
|
||||||
|
case notifyPrint:
|
||||||
|
notify = notifyNone
|
||||||
|
fmt.Println("gio frame ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
|
||||||
|
log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawGL() {
|
||||||
|
C.glClearColor(0, 0, 0, 1)
|
||||||
|
C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
|
||||||
|
view, cleanup := nativeViewFor(ve, size)
|
||||||
|
var nilv C.EGLNativeWindowType
|
||||||
|
if view == nilv {
|
||||||
|
return nil, fmt.Errorf("failed creating native view")
|
||||||
|
}
|
||||||
|
disp := getDisplay(ve)
|
||||||
|
if disp == 0 {
|
||||||
|
return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
var major, minor C.EGLint
|
||||||
|
if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
|
||||||
|
return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
|
||||||
|
srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
|
||||||
|
attribs := []C.EGLint{
|
||||||
|
C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
|
||||||
|
C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
|
||||||
|
C.EGL_BLUE_SIZE, 8,
|
||||||
|
C.EGL_GREEN_SIZE, 8,
|
||||||
|
C.EGL_RED_SIZE, 8,
|
||||||
|
C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
|
||||||
|
}
|
||||||
|
if srgb {
|
||||||
|
// Some drivers need alpha for sRGB framebuffers to work.
|
||||||
|
attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
|
||||||
|
}
|
||||||
|
attribs = append(attribs, C.EGL_NONE)
|
||||||
|
var (
|
||||||
|
cfg C.EGLConfig
|
||||||
|
numCfgs C.EGLint
|
||||||
|
)
|
||||||
|
if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
|
||||||
|
return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
if numCfgs == 0 {
|
||||||
|
supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
|
||||||
|
if !supportsNoCfg {
|
||||||
|
return nil, errors.New("eglChooseConfig returned no configs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctxAttribs := []C.EGLint{
|
||||||
|
C.EGL_CONTEXT_CLIENT_VERSION, 3,
|
||||||
|
C.EGL_NONE,
|
||||||
|
}
|
||||||
|
ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
|
||||||
|
if ctx == nil {
|
||||||
|
return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
var surfAttribs []C.EGLint
|
||||||
|
if srgb {
|
||||||
|
surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
|
||||||
|
}
|
||||||
|
surfAttribs = append(surfAttribs, C.EGL_NONE)
|
||||||
|
surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
|
||||||
|
if surf == nil {
|
||||||
|
return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
|
||||||
|
}
|
||||||
|
return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *eglContext) Release() {
|
||||||
|
if c.ctx != nil {
|
||||||
|
C.eglDestroyContext(c.disp, c.ctx)
|
||||||
|
}
|
||||||
|
if c.surf != nil {
|
||||||
|
C.eglDestroySurface(c.disp, c.surf)
|
||||||
|
}
|
||||||
|
if c.cleanup != nil {
|
||||||
|
c.cleanup()
|
||||||
|
}
|
||||||
|
*c = eglContext{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExtension(exts []string, ext string) bool {
|
||||||
|
for _, e := range exts {
|
||||||
|
if ext == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||||
|
// turns red, going back to its normal color when clicked again.
|
||||||
|
type quarterWidget struct {
|
||||||
|
color color.NRGBA
|
||||||
|
|
||||||
|
clicked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
var color color.NRGBA
|
||||||
|
if w.clicked {
|
||||||
|
color = red
|
||||||
|
} else {
|
||||||
|
color = w.color
|
||||||
|
}
|
||||||
|
|
||||||
|
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||||
|
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||||
|
|
||||||
|
defer clip.Rect(image.Rectangle{
|
||||||
|
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||||
|
}).Push(gtx.Ops).Pop()
|
||||||
|
event.Op(gtx.Ops, w)
|
||||||
|
for {
|
||||||
|
e, ok := gtx.Event(pointer.Filter{
|
||||||
|
Target: w,
|
||||||
|
Kinds: pointer.Press,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||||
|
w.clicked = !w.clicked
|
||||||
|
// notify when we're done updating the frame.
|
||||||
|
notify = notifyInvalidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
// A simple app used for gogio's end-to-end tests.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/io/event"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
w := new(app.Window)
|
||||||
|
if err := loop(w); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
type notifyFrame int
|
||||||
|
|
||||||
|
const (
|
||||||
|
notifyNone notifyFrame = iota
|
||||||
|
notifyInvalidate
|
||||||
|
notifyPrint
|
||||||
|
)
|
||||||
|
|
||||||
|
// notify keeps track of whether we want to print to stdout to notify the user
|
||||||
|
// when a frame is ready. Initially we want to notify about the first frame.
|
||||||
|
var notify = notifyInvalidate
|
||||||
|
|
||||||
|
type (
|
||||||
|
C = layout.Context
|
||||||
|
D = layout.Dimensions
|
||||||
|
)
|
||||||
|
|
||||||
|
func loop(w *app.Window) error {
|
||||||
|
topLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||||
|
}
|
||||||
|
topRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||||
|
}
|
||||||
|
botLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||||
|
}
|
||||||
|
botRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ops op.Ops
|
||||||
|
for {
|
||||||
|
e := w.Event()
|
||||||
|
switch e := e.(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return e.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
gtx := app.NewContext(&ops, e)
|
||||||
|
// Clear background to white, even on embedded platforms such as webassembly.
|
||||||
|
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||||
|
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r1c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||||
|
// r1c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r2c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||||
|
// r2c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
e.Frame(gtx.Ops)
|
||||||
|
|
||||||
|
switch notify {
|
||||||
|
case notifyInvalidate:
|
||||||
|
notify = notifyPrint
|
||||||
|
w.Invalidate()
|
||||||
|
case notifyPrint:
|
||||||
|
notify = notifyNone
|
||||||
|
fmt.Println("gio frame ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||||
|
// turns red, going back to its normal color when clicked again.
|
||||||
|
type quarterWidget struct {
|
||||||
|
color color.NRGBA
|
||||||
|
|
||||||
|
clicked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
var color color.NRGBA
|
||||||
|
if w.clicked {
|
||||||
|
color = red
|
||||||
|
} else {
|
||||||
|
color = w.color
|
||||||
|
}
|
||||||
|
|
||||||
|
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||||
|
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||||
|
|
||||||
|
defer clip.Rect(image.Rectangle{
|
||||||
|
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||||
|
}).Push(gtx.Ops).Pop()
|
||||||
|
event.Op(gtx.Ops, w)
|
||||||
|
filter := pointer.Filter{
|
||||||
|
Target: w,
|
||||||
|
Kinds: pointer.Press,
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
e, ok := gtx.Event(filter)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||||
|
w.clicked = !w.clicked
|
||||||
|
// notify when we're done updating the frame.
|
||||||
|
notify = notifyInvalidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minIOSVersion = 10
|
||||||
|
// Some Metal features require tvOS 11
|
||||||
|
minTVOSVersion = 11
|
||||||
|
// Metal is available from iOS 8 on devices, yet from version 13 on the
|
||||||
|
// simulator.
|
||||||
|
minSimulatorVersion = 13
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildIOS(tmpDir, target string, bi *buildInfo) error {
|
||||||
|
appName := bi.name
|
||||||
|
switch *buildMode {
|
||||||
|
case "archive":
|
||||||
|
framework := *destPath
|
||||||
|
if framework == "" {
|
||||||
|
framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
|
||||||
|
}
|
||||||
|
return archiveIOS(tmpDir, target, framework, bi)
|
||||||
|
case "exe":
|
||||||
|
out := *destPath
|
||||||
|
if out == "" {
|
||||||
|
out = appName + ".ipa"
|
||||||
|
}
|
||||||
|
forDevice := strings.HasSuffix(out, ".ipa")
|
||||||
|
// Filter out unsupported architectures.
|
||||||
|
for i := len(bi.archs) - 1; i >= 0; i-- {
|
||||||
|
switch bi.archs[i] {
|
||||||
|
case "arm", "arm64":
|
||||||
|
if forDevice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "386", "amd64":
|
||||||
|
if !forDevice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
|
||||||
|
}
|
||||||
|
if !forDevice && !strings.HasSuffix(out, ".app") {
|
||||||
|
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
|
||||||
|
}
|
||||||
|
if !forDevice {
|
||||||
|
return exeIOS(tmpDir, target, out, bi)
|
||||||
|
}
|
||||||
|
payload := filepath.Join(tmpDir, "Payload")
|
||||||
|
appDir := filepath.Join(payload, appName+".app")
|
||||||
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := signIOS(bi, tmpDir, appDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zipDir(out, tmpDir, "Payload")
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIOS(bi *buildInfo, tmpDir, app string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
|
||||||
|
provisions, err := filepath.Glob(provPattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provInfo := filepath.Join(tmpDir, "provision.plist")
|
||||||
|
var avail []string
|
||||||
|
for _, prov := range provisions {
|
||||||
|
// Decode the provision file to a plist.
|
||||||
|
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exp, err := time.Parse(time.UnixDate, expUnix)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
|
||||||
|
}
|
||||||
|
if exp.Before(time.Now()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
|
||||||
|
avail = append(avail, provAppID)
|
||||||
|
if expAppID != provAppID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Copy provisioning file.
|
||||||
|
embedded := filepath.Join(app, "embedded.mobileprovision")
|
||||||
|
if err := copyFile(embedded, prov); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Omit trailing newline.
|
||||||
|
certDER = certDER[:len(certDER)-1]
|
||||||
|
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entFile := filepath.Join(tmpDir, "entitlements.plist")
|
||||||
|
if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity := sha1.Sum(certDER)
|
||||||
|
idHex := hex.EncodeToString(identity[:])
|
||||||
|
_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
|
||||||
|
if bi.appID == "" {
|
||||||
|
return errors.New("app id is empty; use -appid to set it")
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(app, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
appName := UppercaseName(bi.name)
|
||||||
|
exe := filepath.Join(app, appName)
|
||||||
|
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||||
|
var builds errgroup.Group
|
||||||
|
for _, a := range bi.archs {
|
||||||
|
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cflags = append(cflags,
|
||||||
|
"-fobjc-arc",
|
||||||
|
)
|
||||||
|
cflagsLine := strings.Join(cflags, " ")
|
||||||
|
exeSlice := filepath.Join(tmpDir, "app-"+a)
|
||||||
|
lipo.Args = append(lipo.Args, exeSlice)
|
||||||
|
compile := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags=-s -w "+bi.ldflags,
|
||||||
|
"-o", exeSlice,
|
||||||
|
"-tags", bi.tags,
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
compile.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=ios",
|
||||||
|
"GOARCH="+a,
|
||||||
|
"CGO_ENABLED=1",
|
||||||
|
"CC="+clang,
|
||||||
|
"CGO_CFLAGS="+cflagsLine,
|
||||||
|
"CGO_LDFLAGS=-lresolv "+cflagsLine,
|
||||||
|
)
|
||||||
|
builds.Go(func() error {
|
||||||
|
_, err := runCmd(compile)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := builds.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := runCmd(lipo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
infoPlist := buildInfoPlist(bi)
|
||||||
|
plistFile := filepath.Join(app, "Info.plist")
|
||||||
|
if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||||
|
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Merge assets plist with Info.plist
|
||||||
|
cmd := exec.Command(
|
||||||
|
"/usr/libexec/PlistBuddy",
|
||||||
|
"-c", "Merge "+assetPlist,
|
||||||
|
plistFile,
|
||||||
|
)
|
||||||
|
if _, err := runCmd(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
|
||||||
|
// iosIcons returns the asset plist file to be merged into Info.plist.
|
||||||
|
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
|
||||||
|
assets := filepath.Join(tmpDir, "Assets.xcassets")
|
||||||
|
if err := os.Mkdir(assets, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
appIcon := filepath.Join(assets, "AppIcon.appiconset")
|
||||||
|
err := buildIcons(appIcon, icon, []iconVariant{
|
||||||
|
{path: "ios_2x.png", size: 120},
|
||||||
|
{path: "ios_3x.png", size: 180},
|
||||||
|
// The App Store icon is not allowed to contain
|
||||||
|
// transparent pixels.
|
||||||
|
{path: "ios_store.png", size: 1024, fill: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
contentJson := `{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "ios_2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "ios_3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "ios_store.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
contentFile := filepath.Join(appIcon, "Contents.json")
|
||||||
|
if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assetPlist := filepath.Join(tmpDir, "assets.plist")
|
||||||
|
|
||||||
|
minsdk := bi.minsdk
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minIOSVersion
|
||||||
|
}
|
||||||
|
compile := exec.Command(
|
||||||
|
"actool",
|
||||||
|
"--compile", appDir,
|
||||||
|
"--platform", iosPlatformFor(bi.target),
|
||||||
|
"--minimum-deployment-target", strconv.Itoa(minsdk),
|
||||||
|
"--app-icon", "AppIcon",
|
||||||
|
"--output-partial-info-plist", assetPlist,
|
||||||
|
assets)
|
||||||
|
_, err = runCmd(compile)
|
||||||
|
return assetPlist, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfoPlist(bi *buildInfo) string {
|
||||||
|
appName := UppercaseName(bi.name)
|
||||||
|
platform := iosPlatformFor(bi.target)
|
||||||
|
var supportPlatform string
|
||||||
|
switch bi.target {
|
||||||
|
case "ios":
|
||||||
|
supportPlatform = "iPhoneOS"
|
||||||
|
case "tvos":
|
||||||
|
supportPlatform = "AppleTVOS"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>%d</string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array><string>arm64</string></array>
|
||||||
|
<key>DTPlatformName</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>DTPlatformVersion</key>
|
||||||
|
<string>12.4</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>%d</string>
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<integer>1</integer>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleSupportedPlatforms</key>
|
||||||
|
<array>
|
||||||
|
<string>%s</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>DTCompiler</key>
|
||||||
|
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||||
|
<key>DTPlatformBuild</key>
|
||||||
|
<string>16G73</string>
|
||||||
|
<key>DTSDKBuild</key>
|
||||||
|
<string>16G73</string>
|
||||||
|
<key>DTSDKName</key>
|
||||||
|
<string>%s12.4</string>
|
||||||
|
<key>DTXcode</key>
|
||||||
|
<string>1030</string>
|
||||||
|
<key>DTXcodeBuild</key>
|
||||||
|
<string>10G8</string>
|
||||||
|
</dict>
|
||||||
|
</plist>`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iosPlatformFor(target string) string {
|
||||||
|
switch target {
|
||||||
|
case "ios":
|
||||||
|
return "iphoneos"
|
||||||
|
case "tvos":
|
||||||
|
return "appletvos"
|
||||||
|
default:
|
||||||
|
panic("invalid platform " + target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
|
||||||
|
framework := filepath.Base(frameworkRoot)
|
||||||
|
const suf = ".framework"
|
||||||
|
if !strings.HasSuffix(framework, suf) {
|
||||||
|
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
|
||||||
|
}
|
||||||
|
framework = framework[:len(framework)-len(suf)]
|
||||||
|
if err := os.RemoveAll(frameworkRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
|
||||||
|
for _, dir := range []string{"Headers", "Modules"} {
|
||||||
|
p := filepath.Join(frameworkDir, dir)
|
||||||
|
if err := os.MkdirAll(p, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
symlinks := [][2]string{
|
||||||
|
{"Versions/Current/Headers", "Headers"},
|
||||||
|
{"Versions/Current/Modules", "Modules"},
|
||||||
|
{"Versions/Current/" + framework, framework},
|
||||||
|
{"A", filepath.Join("Versions", "Current")},
|
||||||
|
}
|
||||||
|
for _, l := range symlinks {
|
||||||
|
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exe := filepath.Join(frameworkDir, framework)
|
||||||
|
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||||
|
var builds errgroup.Group
|
||||||
|
tags := bi.tags
|
||||||
|
for _, a := range bi.archs {
|
||||||
|
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lib := filepath.Join(tmpDir, "gio-"+a)
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags=-s -w "+bi.ldflags,
|
||||||
|
"-buildmode=c-archive",
|
||||||
|
"-o", lib,
|
||||||
|
"-tags", tags,
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
lipo.Args = append(lipo.Args, lib)
|
||||||
|
cflagsLine := strings.Join(cflags, " ")
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=ios",
|
||||||
|
"GOARCH="+a,
|
||||||
|
"CGO_ENABLED=1",
|
||||||
|
"CC="+clang,
|
||||||
|
"CGO_CFLAGS="+cflagsLine,
|
||||||
|
"CGO_LDFLAGS="+cflagsLine,
|
||||||
|
)
|
||||||
|
builds.Go(func() error {
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := builds.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := runCmd(lipo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
|
||||||
|
headerSrc := filepath.Join(appDir, "framework_ios.h")
|
||||||
|
if err := copyFile(headerDst, headerSrc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
module := fmt.Sprintf(`framework module "%s" {
|
||||||
|
header "%[1]s.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
}`, framework)
|
||||||
|
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
|
||||||
|
return os.WriteFile(moduleFile, []byte(module), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
|
||||||
|
var (
|
||||||
|
platformSDK string
|
||||||
|
platformOS string
|
||||||
|
)
|
||||||
|
switch target {
|
||||||
|
case "ios":
|
||||||
|
platformOS = "ios"
|
||||||
|
platformSDK = "iphone"
|
||||||
|
case "tvos":
|
||||||
|
platformOS = "tvos"
|
||||||
|
platformSDK = "appletv"
|
||||||
|
}
|
||||||
|
switch arch {
|
||||||
|
case "arm", "arm64":
|
||||||
|
platformSDK += "os"
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minIOSVersion
|
||||||
|
if target == "tvos" {
|
||||||
|
minsdk = minTVOSVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "386", "amd64":
|
||||||
|
platformOS += "-simulator"
|
||||||
|
platformSDK += "simulator"
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minSimulatorVersion
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
|
||||||
|
}
|
||||||
|
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
cflags := []string{
|
||||||
|
"-fembed-bitcode",
|
||||||
|
"-arch", allArchs[arch].iosArch,
|
||||||
|
"-isysroot", sdkPath,
|
||||||
|
"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
|
||||||
|
}
|
||||||
|
return clang, cflags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipDir(dst, base, dir string) (err error) {
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
zipf := zip.NewWriter(f)
|
||||||
|
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel := filepath.ToSlash(path[len(base)+1:])
|
||||||
|
entry, err := zipf.Create(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
_, err = io.Copy(entry, src)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zipf.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
|
||||||
|
)
|
||||||
|
|
||||||
|
type JSTestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
// ctx is the chromedp context.
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Start(path string) {
|
||||||
|
if raceEnabled {
|
||||||
|
d.Skipf("js/wasm doesn't support -race; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
dir := d.tempDir("gio-endtoend-js")
|
||||||
|
d.gogio("-target=js", "-o="+dir, path)
|
||||||
|
|
||||||
|
// Second, start Chrome.
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.Flag("headless", *headless),
|
||||||
|
)
|
||||||
|
|
||||||
|
actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
|
||||||
|
ctx, cancel := chromedp.NewContext(actx,
|
||||||
|
// Send all logf/errf calls to t.Logf
|
||||||
|
chromedp.WithLogf(d.Logf),
|
||||||
|
)
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
d.ctx = ctx
|
||||||
|
|
||||||
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
d.Skipf("test requires Chrome to be installed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
d.Cleanup(func() { pw.Close() })
|
||||||
|
d.output = pr
|
||||||
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
|
switch ev := ev.(type) {
|
||||||
|
case *runtime.EventConsoleAPICalled:
|
||||||
|
switch ev.Type {
|
||||||
|
case "log", "info", "warning", "error":
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString("console.")
|
||||||
|
b.WriteString(string(ev.Type))
|
||||||
|
b.WriteString("(")
|
||||||
|
for i, arg := range ev.Args {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(", ")
|
||||||
|
}
|
||||||
|
b.Write(arg.Value)
|
||||||
|
}
|
||||||
|
b.WriteString(")\n")
|
||||||
|
pw.Write(b.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Third, serve the app folder, set the browser tab dimensions, and
|
||||||
|
// navigate to the folder.
|
||||||
|
ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
|
||||||
|
d.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
if err := chromedp.Run(ctx,
|
||||||
|
chromedp.EmulateViewport(int64(d.width), int64(d.height)),
|
||||||
|
chromedp.Navigate(ts.URL),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Screenshot() image.Image {
|
||||||
|
var buf []byte
|
||||||
|
if err := chromedp.Run(d.ctx,
|
||||||
|
chromedp.CaptureScreenshot(&buf),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Click(x, y int) {
|
||||||
|
if err := chromedp.Run(d.ctx,
|
||||||
|
chromedp.MouseClickXY(float64(x), float64(y)),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"golang.org/x/tools/go/packages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildJS(bi *buildInfo) error {
|
||||||
|
out := *destPath
|
||||||
|
if out == "" {
|
||||||
|
out = bi.name
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(out, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags="+bi.ldflags,
|
||||||
|
"-tags="+bi.tags,
|
||||||
|
"-o", filepath.Join(out, "main.wasm"),
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=js",
|
||||||
|
"GOARCH=wasm",
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var faviconPath string
|
||||||
|
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||||
|
// Copy icon to the output folder
|
||||||
|
icon, err := os.ReadFile(bi.iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
faviconPath = filepath.Base(bi.iconPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexTemplate, err := template.New("").Parse(jsIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := indexTemplate.Execute(&b, struct {
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
}{
|
||||||
|
Name: bi.name,
|
||||||
|
Icon: faviconPath,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
|
||||||
|
if _, err := os.Stat(wasmJS); err != nil {
|
||||||
|
return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
|
||||||
|
}
|
||||||
|
pkgs, err := packages.Load(&packages.Config{
|
||||||
|
Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
|
||||||
|
Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
|
||||||
|
}, bi.pkgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
|
||||||
|
if len(p.GoFiles) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
extraJS = append(extraJS, js...)
|
||||||
|
for _, imp := range p.Imports {
|
||||||
|
if !visited[imp.ID] {
|
||||||
|
extra, err := findPackagesJS(imp, visited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
extraJS = append(extraJS, extra...)
|
||||||
|
visited[imp.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extraJS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
|
||||||
|
// and append the jsStartGo.
|
||||||
|
func mergeJSFiles(dst string, files ...string) (err error) {
|
||||||
|
w, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := w.Close(); err != nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = io.Copy(w, strings.NewReader(jsSetGo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range files {
|
||||||
|
r, err := os.Open(files[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
r.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, strings.NewReader(jsStartGo))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
jsIndex = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
{{ if .Icon }}<link rel="icon" href="{{.Icon}}" type="image/x-icon" />{{ end }}
|
||||||
|
{{ if .Name }}<title>{{.Name}}</title>{{ end }}
|
||||||
|
<script src="wasm.js"></script>
|
||||||
|
<style>
|
||||||
|
body,pre { margin:0;padding:0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
// jsSetGo sets the `window.go` variable.
|
||||||
|
jsSetGo = `(() => {
|
||||||
|
window.go = {argv: [], env: {}, importObject: {go: {}}};
|
||||||
|
const argv = new URLSearchParams(location.search).get("argv");
|
||||||
|
if (argv) {
|
||||||
|
window.go["argv"] = argv.split(" ");
|
||||||
|
}
|
||||||
|
})();`
|
||||||
|
// jsStartGo initializes the main.wasm.
|
||||||
|
jsStartGo = `(() => {
|
||||||
|
defaultGo = new Go();
|
||||||
|
Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
|
||||||
|
Object.assign(defaultGo["env"], go["env"]);
|
||||||
|
for (let key in go["importObject"]) {
|
||||||
|
if (typeof defaultGo["importObject"][key] === "undefined") {
|
||||||
|
defaultGo["importObject"][key] = {};
|
||||||
|
}
|
||||||
|
Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
|
||||||
|
}
|
||||||
|
window.go = defaultGo;
|
||||||
|
if (!WebAssembly.instantiateStreaming) { // polyfill
|
||||||
|
WebAssembly.instantiateStreaming = async (resp, importObject) => {
|
||||||
|
const source = await (await resp).arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(source, importObject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
|
||||||
|
go.run(result.instance);
|
||||||
|
});
|
||||||
|
})();`
|
||||||
|
)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildMac(tmpDir string, bi *buildInfo) error {
|
||||||
|
builder := &macBuilder{TempDir: tmpDir}
|
||||||
|
builder.DestDir = *destPath
|
||||||
|
if builder.DestDir == "" {
|
||||||
|
builder.DestDir = bi.pkgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
name := bi.name
|
||||||
|
if *destPath != "" {
|
||||||
|
if filepath.Ext(*destPath) != ".app" {
|
||||||
|
return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
|
||||||
|
}
|
||||||
|
name = filepath.Base(*destPath)
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, ".app")
|
||||||
|
|
||||||
|
if bi.appID == "" {
|
||||||
|
return errors.New("app id is empty; use -appid to set it")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.setIcon(bi.iconPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.setInfo(bi, name); err != nil {
|
||||||
|
return fmt.Errorf("can't build the resources: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arch := range bi.archs {
|
||||||
|
tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir))
|
||||||
|
finalDest := builder.DestDir
|
||||||
|
if len(bi.archs) > 1 {
|
||||||
|
tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app")
|
||||||
|
finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bi.key != "" {
|
||||||
|
if err := builder.signProgram(bi, tmpDest, name, arch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dittozip(tmpDest, tmpDest+".zip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bi.notaryAppleID != "" {
|
||||||
|
if err := builder.notarize(bi, tmpDest+".zip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dittounzip(tmpDest+".zip", finalDest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type macBuilder struct {
|
||||||
|
TempDir string
|
||||||
|
DestDir string
|
||||||
|
|
||||||
|
Icons []byte
|
||||||
|
Manifest []byte
|
||||||
|
Entitlements []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) setIcon(path string) (err error) {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(b.TempDir, "iconset.iconset")
|
||||||
|
if err := os.MkdirAll(out, 0777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = buildIcons(out, path, []iconVariant{
|
||||||
|
{path: "icon_512x512@2x.png", size: 1024},
|
||||||
|
{path: "icon_512x512.png", size: 512},
|
||||||
|
{path: "icon_256x256@2x.png", size: 512},
|
||||||
|
{path: "icon_256x256.png", size: 256},
|
||||||
|
{path: "icon_128x128@2x.png", size: 256},
|
||||||
|
{path: "icon_128x128.png", size: 128},
|
||||||
|
{path: "icon_64x64@2x.png", size: 128},
|
||||||
|
{path: "icon_64x64.png", size: 64},
|
||||||
|
{path: "icon_32x32@2x.png", size: 64},
|
||||||
|
{path: "icon_32x32.png", size: 32},
|
||||||
|
{path: "icon_16x16@2x.png", size: 32},
|
||||||
|
{path: "icon_16x16.png", size: 16},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("iconutil",
|
||||||
|
"-c", "icns", out,
|
||||||
|
"-o", filepath.Join(b.TempDir, "icon.icns"))
|
||||||
|
if _, err := runCmd(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
|
||||||
|
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icon.icns</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>{{.Bundle}}</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest bufferCoff
|
||||||
|
if err := t.Execute(&manifest, struct {
|
||||||
|
Name, Bundle string
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
Bundle: buildInfo.appID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.Manifest = manifest.Bytes()
|
||||||
|
|
||||||
|
b.Entitlements = []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>`)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
|
||||||
|
for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b.Icons) > 0 {
|
||||||
|
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags="+buildInfo.ldflags,
|
||||||
|
"-tags="+buildInfo.tags,
|
||||||
|
"-o", filepath.Join(binDest, "/Contents/MacOS/"+name),
|
||||||
|
buildInfo.pkgPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=darwin",
|
||||||
|
"GOARCH="+arch,
|
||||||
|
"CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
|
||||||
|
options := filepath.Join(b.TempDir, "ent.ent")
|
||||||
|
if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
xattr := exec.Command("xattr", "-rc", binDest)
|
||||||
|
if _, err := runCmd(xattr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"codesign",
|
||||||
|
"--deep",
|
||||||
|
"--force",
|
||||||
|
"--options", "runtime",
|
||||||
|
"--entitlements", options,
|
||||||
|
"--sign", buildInfo.key,
|
||||||
|
binDest,
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"xcrun",
|
||||||
|
"notarytool",
|
||||||
|
"submit",
|
||||||
|
binDest,
|
||||||
|
"--apple-id", buildInfo.notaryAppleID,
|
||||||
|
"--team-id", buildInfo.notaryTeamID,
|
||||||
|
"--wait",
|
||||||
|
)
|
||||||
|
|
||||||
|
if buildInfo.notaryPassword != "" {
|
||||||
|
cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dittozip(input, output string) error {
|
||||||
|
cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output)
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dittounzip(input, output string) error {
|
||||||
|
cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output)
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
|
||||||
|
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
|
||||||
|
minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
|
||||||
|
targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android")
|
||||||
|
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
|
||||||
|
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
|
||||||
|
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
|
||||||
|
name = flag.String("name", "", "app name (for -buildmode=exe)")
|
||||||
|
version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode")
|
||||||
|
printCommands = flag.Bool("x", false, "print the commands")
|
||||||
|
keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
|
||||||
|
linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
|
||||||
|
extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker")
|
||||||
|
extraTags = flag.String("tags", "", "extra tags to the Go tool")
|
||||||
|
iconPath = flag.String("icon", "", "specify an icon for iOS and Android")
|
||||||
|
signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
|
||||||
|
signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.")
|
||||||
|
notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.")
|
||||||
|
notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.")
|
||||||
|
notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprint(os.Stderr, mainUsage)
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
if err := flagValidate(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
buildInfo, err := newBuildInfo(flag.Arg(0))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := build(buildInfo); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagValidate() error {
|
||||||
|
pkgPathArg := flag.Arg(0)
|
||||||
|
if pkgPathArg == "" {
|
||||||
|
return errors.New("specify a package")
|
||||||
|
}
|
||||||
|
if *target == "" {
|
||||||
|
return errors.New("please specify -target")
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "ios", "tvos", "android", "js", "windows", "macos":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid -target %s", *target)
|
||||||
|
}
|
||||||
|
switch *buildMode {
|
||||||
|
case "archive", "exe":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid -buildmode %s", *buildMode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func build(bi *buildInfo) error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gogio-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *keepWorkdir {
|
||||||
|
fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
|
||||||
|
} else {
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "js":
|
||||||
|
return buildJS(bi)
|
||||||
|
case "ios", "tvos":
|
||||||
|
return buildIOS(tmpDir, *target, bi)
|
||||||
|
case "android":
|
||||||
|
return buildAndroid(tmpDir, bi)
|
||||||
|
case "windows":
|
||||||
|
return buildWindows(tmpDir, bi)
|
||||||
|
case "macos":
|
||||||
|
return buildMac(tmpDir, bi)
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
|
||||||
|
if *printCommands {
|
||||||
|
fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
|
||||||
|
}
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err, ok := err.(*exec.ExitError); ok {
|
||||||
|
return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmd(cmd *exec.Cmd) (string, error) {
|
||||||
|
out, err := runCmdRaw(cmd)
|
||||||
|
return string(bytes.TrimSpace(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(dst, src string) (err error) {
|
||||||
|
r, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
w, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := w.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type arch struct {
|
||||||
|
iosArch string
|
||||||
|
jniArch string
|
||||||
|
clangArch string
|
||||||
|
}
|
||||||
|
|
||||||
|
var allArchs = map[string]arch{
|
||||||
|
"arm": {
|
||||||
|
iosArch: "armv7",
|
||||||
|
jniArch: "armeabi-v7a",
|
||||||
|
clangArch: "armv7a-linux-androideabi",
|
||||||
|
},
|
||||||
|
"arm64": {
|
||||||
|
iosArch: "arm64",
|
||||||
|
jniArch: "arm64-v8a",
|
||||||
|
clangArch: "aarch64-linux-android",
|
||||||
|
},
|
||||||
|
"386": {
|
||||||
|
iosArch: "i386",
|
||||||
|
jniArch: "x86",
|
||||||
|
clangArch: "i686-linux-android",
|
||||||
|
},
|
||||||
|
"amd64": {
|
||||||
|
iosArch: "x86_64",
|
||||||
|
jniArch: "x86_64",
|
||||||
|
clangArch: "x86_64-linux-android",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type iconVariant struct {
|
||||||
|
path string
|
||||||
|
size int
|
||||||
|
fill bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildIcons(baseDir, icon string, variants []iconVariant) error {
|
||||||
|
f, err := os.Open(icon)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resizes errgroup.Group
|
||||||
|
for _, v := range variants {
|
||||||
|
v := v
|
||||||
|
resizes.Go(func() (err error) {
|
||||||
|
path := filepath.Join(baseDir, v.path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return png.Encode(f, resizeIcon(v, img))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resizes.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
|
||||||
|
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
|
||||||
|
op := draw.Src
|
||||||
|
if v.fill {
|
||||||
|
op = draw.Over
|
||||||
|
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
|
||||||
|
|
||||||
|
return scaled
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv("RUN_GOGIO") != "" {
|
||||||
|
// Allow the end-to-end tests to call the gogio tool without
|
||||||
|
// having to build it from scratch, nor having to refactor the
|
||||||
|
// main function to avoid using global variables.
|
||||||
|
main()
|
||||||
|
os.Exit(0) // main already exits, but just in case.
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
var AndroidPermissions = map[string][]string{
|
||||||
|
"network": {
|
||||||
|
"android.permission.INTERNET",
|
||||||
|
},
|
||||||
|
"networkstate": {
|
||||||
|
"android.permission.ACCESS_NETWORK_STATE",
|
||||||
|
},
|
||||||
|
"bluetooth": {
|
||||||
|
"android.permission.BLUETOOTH",
|
||||||
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
|
},
|
||||||
|
"wakelock": {
|
||||||
|
"android.permission.WAKE_LOCK",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var AndroidFeatures = map[string][]string{
|
||||||
|
"default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
|
||||||
|
"bluetooth": {
|
||||||
|
`name="android.hardware.bluetooth"`,
|
||||||
|
`name="android.hardware.bluetooth_le"`,
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
`name="android.hardware.camera"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
//go:build race
|
||||||
|
// +build race
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
func init() { raceEnabled = true }
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WaylandTestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
runtimeDir string
|
||||||
|
socket string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bars or anything fancy. Just a white background with our dimensions.
|
||||||
|
var tmplSwayConfig = template.Must(template.New("").Parse(`
|
||||||
|
output * bg #FFFFFF solid_color
|
||||||
|
output * mode {{.Width}}x{{.Height}}
|
||||||
|
default_border none
|
||||||
|
`))
|
||||||
|
|
||||||
|
var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`)
|
||||||
|
|
||||||
|
func (d *WaylandTestDriver) Start(path string) {
|
||||||
|
// We want os.Environ, so that it can e.g. find $DISPLAY to run within
|
||||||
|
// X11. wlroots env vars are documented at:
|
||||||
|
// https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md
|
||||||
|
env := os.Environ()
|
||||||
|
if *headless {
|
||||||
|
env = append(env, "WLR_BACKENDS=headless")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.needPrograms(
|
||||||
|
"sway", // to run a wayland compositor
|
||||||
|
"grim", // to take screenshots
|
||||||
|
"swaymsg", // to send input
|
||||||
|
)
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
dir := d.tempDir("gio-endtoend-wayland")
|
||||||
|
bin := filepath.Join(dir, "red")
|
||||||
|
flags := []string{"build", "-tags", "nox11", "-o=" + bin}
|
||||||
|
if raceEnabled {
|
||||||
|
flags = append(flags, "-race")
|
||||||
|
}
|
||||||
|
flags = append(flags, path)
|
||||||
|
cmd := exec.Command("go", flags...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Fatalf("could not build app: %s:\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := filepath.Join(dir, "config")
|
||||||
|
f, err := os.Create(conf)
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{
|
||||||
|
d.width, d.height,
|
||||||
|
}); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.socket = filepath.Join(dir, "socket")
|
||||||
|
env = append(env, "SWAYSOCK="+d.socket)
|
||||||
|
d.runtimeDir = dir
|
||||||
|
env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
d.Cleanup(wg.Wait)
|
||||||
|
|
||||||
|
// First, start sway.
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose")
|
||||||
|
cmd.Env = env
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
d.Cleanup(func() {
|
||||||
|
// Give it a chance to exit gracefully, cleaning up
|
||||||
|
// after itself. After 10ms, the deferred cancel above
|
||||||
|
// will signal an os.Kill.
|
||||||
|
cmd.Process.Signal(os.Interrupt)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for sway to be ready. We probably don't need a deadline
|
||||||
|
// here.
|
||||||
|
br := bufio.NewReader(stderr)
|
||||||
|
for {
|
||||||
|
line, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := rxSwayReady.FindStringSubmatch(line); m != nil {
|
||||||
|
d.display = m[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") {
|
||||||
|
// Don't print all stderr, since we use --verbose.
|
||||||
|
// TODO(mvdan): if it's useful, probably filter
|
||||||
|
// errors and show them.
|
||||||
|
d.Error(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, start our program on the sway compositor above.
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, bin)
|
||||||
|
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
d.output = output
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
d.Error(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WaylandTestDriver) Screenshot() image.Image {
|
||||||
|
cmd := exec.Command("grim", "/dev/stdout")
|
||||||
|
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WaylandTestDriver) swaymsg(args ...interface{}) {
|
||||||
|
strs := []string{"--socket", d.socket}
|
||||||
|
for _, arg := range args {
|
||||||
|
strs = append(strs, fmt.Sprint(arg))
|
||||||
|
}
|
||||||
|
cmd := exec.Command("swaymsg", strs...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WaylandTestDriver) Click(x, y int) {
|
||||||
|
d.swaymsg("seat", "-", "cursor", "set", x, y)
|
||||||
|
d.swaymsg("seat", "-", "cursor", "press", "button1")
|
||||||
|
d.swaymsg("seat", "-", "cursor", "release", "button1")
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wine is tightly coupled with X11 at the moment, and we can reuse the same
|
||||||
|
// methods to automate screenshots and clicks. The main difference is how we
|
||||||
|
// build and run the app.
|
||||||
|
|
||||||
|
// The only quirk is that it seems impossible for the Wine window to take the
|
||||||
|
// entirety of the X server's dimensions, even if we try to resize it to take
|
||||||
|
// the entire display. It seems to want to leave some vertical space empty,
|
||||||
|
// presumably for window decorations or the "start" bar on Windows. To work
|
||||||
|
// around that, make the X server 50x50px bigger, and crop the screenshots back
|
||||||
|
// to the original size.
|
||||||
|
|
||||||
|
type WineTestDriver struct {
|
||||||
|
X11TestDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WineTestDriver) Start(path string) {
|
||||||
|
d.needPrograms("wine")
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe")
|
||||||
|
flags := []string{"build", "-o=" + bin}
|
||||||
|
if raceEnabled {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
// cross-compilation disables CGo, which breaks -race.
|
||||||
|
d.Skipf("can't cross-compile -race for Windows; skipping")
|
||||||
|
}
|
||||||
|
flags = append(flags, "-race")
|
||||||
|
}
|
||||||
|
flags = append(flags, path)
|
||||||
|
cmd := exec.Command("go", flags...)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
cmd.Env = append(cmd.Env, "GOOS=windows")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Fatalf("could not build app: %s:\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
d.Cleanup(wg.Wait)
|
||||||
|
|
||||||
|
// Add 50x50px to the display dimensions, as discussed earlier.
|
||||||
|
d.startServer(&wg, d.width+50, d.height+50)
|
||||||
|
|
||||||
|
// Then, start our program via Wine on the X server above.
|
||||||
|
{
|
||||||
|
cacheDir, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
// Use a wine directory separate from the default ~/.wine, so
|
||||||
|
// that the user's winecfg doesn't affect our test. This will
|
||||||
|
// default to ~/.cache/gio-e2e-wine. We use the user's cache,
|
||||||
|
// to reuse a previously set up wineprefix.
|
||||||
|
wineprefix := filepath.Join(cacheDir, "gio-e2e-wine")
|
||||||
|
|
||||||
|
// First, ensure that wineprefix is up to date with wineboot.
|
||||||
|
// Wait for this separately from the first frame, as setting up
|
||||||
|
// a new prefix might take 5s on its own.
|
||||||
|
env := []string{
|
||||||
|
"DISPLAY=" + d.display,
|
||||||
|
"WINEDEBUG=fixme-all", // hide "fixme" noise
|
||||||
|
"WINEPREFIX=" + wineprefix,
|
||||||
|
|
||||||
|
// Disable wine-gecko (Explorer) and wine-mono (.NET).
|
||||||
|
// Otherwise, if not installed, wineboot will get stuck
|
||||||
|
// with a prompt to install them on the virtual X
|
||||||
|
// display. Moreover, Gio doesn't need either, and wine
|
||||||
|
// is faster without them.
|
||||||
|
"WINEDLLOVERRIDES=mscoree,mshtml=",
|
||||||
|
}
|
||||||
|
{
|
||||||
|
start := time.Now()
|
||||||
|
cmd := exec.Command("wine", "wineboot", "-i")
|
||||||
|
cmd.Env = env
|
||||||
|
// Use a combined output pipe instead of CombinedOutput,
|
||||||
|
// so that we only wait for the child process to exit,
|
||||||
|
// and we don't need to wait for all of wine's
|
||||||
|
// grandchildren to exit and stop writing. This is
|
||||||
|
// relevant as wine leaves "wineserver" lingering for
|
||||||
|
// three seconds by default, to be reused later.
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
io.Copy(os.Stderr, stdout)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Logf("set up WINEPREFIX in %s", time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, "wine", bin)
|
||||||
|
cmd.Env = env
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
d.output = output
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
d.Error(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
|
||||||
|
// xdotool seems to fail at actually moving the window if we use it
|
||||||
|
// immediately after Gio is ready. Why?
|
||||||
|
// We can't tell if the windowmove operation worked until we take a
|
||||||
|
// screenshot, because the getwindowgeometry op reports the 0x0
|
||||||
|
// coordinates even if the window wasn't moved properly.
|
||||||
|
// A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that.
|
||||||
|
// TODO(mvdan): revisit this, when you have a spare three hours.
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio")
|
||||||
|
d.xdotool("windowmove", "--sync", id, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *WineTestDriver) Screenshot() image.Image {
|
||||||
|
img := d.X11TestDriver.Screenshot()
|
||||||
|
// Crop the screenshot back to the original dimensions.
|
||||||
|
cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height))
|
||||||
|
draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src)
|
||||||
|
return cropped
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/akavel/rsrc/binutil"
|
||||||
|
"github.com/akavel/rsrc/coff"
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildWindows(tmpDir string, bi *buildInfo) error {
|
||||||
|
builder := &windowsBuilder{TempDir: tmpDir}
|
||||||
|
builder.DestDir = *destPath
|
||||||
|
if builder.DestDir == "" {
|
||||||
|
builder.DestDir = bi.pkgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
name := bi.name
|
||||||
|
if *destPath != "" {
|
||||||
|
if filepath.Ext(*destPath) != ".exe" {
|
||||||
|
return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
|
||||||
|
}
|
||||||
|
name = filepath.Base(*destPath)
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, ".exe")
|
||||||
|
sdk := bi.minsdk
|
||||||
|
if sdk > 10 {
|
||||||
|
return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arch := range bi.archs {
|
||||||
|
builder.Coff = coff.NewRSRC()
|
||||||
|
builder.Coff.Arch(arch)
|
||||||
|
|
||||||
|
if err := builder.embedIcon(bi.iconPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.embedManifest(windowsManifest{
|
||||||
|
Version: bi.version.String(),
|
||||||
|
WindowsVersion: sdk,
|
||||||
|
Name: name,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("can't create manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.embedInfo(windowsResources{
|
||||||
|
Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)},
|
||||||
|
VersionHuman: bi.version.String(),
|
||||||
|
Name: name,
|
||||||
|
Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10)
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("can't create info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.buildResource(bi, name, arch); err != nil {
|
||||||
|
return fmt.Errorf("can't build the resources: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.buildProgram(bi, name, arch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
windowsResources struct {
|
||||||
|
Version [2]uint32
|
||||||
|
VersionHuman string
|
||||||
|
Language uint16
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
windowsManifest struct {
|
||||||
|
Version string
|
||||||
|
WindowsVersion int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
windowsBuilder struct {
|
||||||
|
TempDir string
|
||||||
|
DestDir string
|
||||||
|
Coff *coff.Coff
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types
|
||||||
|
windowsResourceIcon = 3
|
||||||
|
windowsResourceIconGroup = windowsResourceIcon + 11
|
||||||
|
windowsResourceManifest = 24
|
||||||
|
windowsResourceVersion = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
type bufferCoff struct {
|
||||||
|
bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bufferCoff) Size() int64 {
|
||||||
|
return int64(b.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *windowsBuilder) embedIcon(path string) (err error) {
|
||||||
|
iconFile, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("can't read the icon located at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer iconFile.Close()
|
||||||
|
|
||||||
|
iconImage, err := png.Decode(iconFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("can't decode the PNG file (%s): %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes := []int{16, 32, 48, 64, 128, 256}
|
||||||
|
var iconHeader bufferCoff
|
||||||
|
|
||||||
|
// GRPICONDIR structure.
|
||||||
|
if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range sizes {
|
||||||
|
var iconBuffer bufferCoff
|
||||||
|
|
||||||
|
if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil {
|
||||||
|
return fmt.Errorf("can't encode image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer)
|
||||||
|
|
||||||
|
if err := binary.Write(&iconHeader, binary.LittleEndian, struct {
|
||||||
|
Size [2]uint8
|
||||||
|
Color [2]uint8
|
||||||
|
Planes uint16
|
||||||
|
BitCount uint16
|
||||||
|
Length uint32
|
||||||
|
Id uint16
|
||||||
|
}{
|
||||||
|
Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px.
|
||||||
|
Planes: 1,
|
||||||
|
BitCount: 32,
|
||||||
|
Length: uint32(iconBuffer.Len()),
|
||||||
|
Id: uint16(size),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error {
|
||||||
|
out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
b.Coff.Freeze()
|
||||||
|
|
||||||
|
// See https://github.com/akavel/rsrc/internal/write.go#L13.
|
||||||
|
w := binutil.Writer{W: out}
|
||||||
|
binutil.Walk(b.Coff, func(v reflect.Value, path string) error {
|
||||||
|
if binutil.Plain(v.Kind()) {
|
||||||
|
w.WriteLE(v.Interface())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
vv, ok := v.Interface().(binutil.SizedReader)
|
||||||
|
if ok {
|
||||||
|
w.WriteFromSized(vv)
|
||||||
|
return binutil.WALK_SKIP
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Err != nil {
|
||||||
|
return fmt.Errorf("error writing output file: %s", w.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
|
||||||
|
dest := b.DestDir
|
||||||
|
if len(buildInfo.archs) > 1 {
|
||||||
|
dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags=-H=windowsgui "+buildInfo.ldflags,
|
||||||
|
"-tags="+buildInfo.tags,
|
||||||
|
"-o", dest,
|
||||||
|
buildInfo.pkgPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=windows",
|
||||||
|
"GOARCH="+arch,
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *windowsBuilder) embedManifest(v windowsManifest) error {
|
||||||
|
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<assemblyIdentity type="win32" name="{{.Name}}" version="{{.Version}}" />
|
||||||
|
<description>{{.Name}}</description>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
{{if (le .WindowsVersion 10)}}<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
{{end}}
|
||||||
|
{{if (le .WindowsVersion 9)}}<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
{{end}}
|
||||||
|
{{if (le .WindowsVersion 8)}}<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
{{end}}
|
||||||
|
{{if (le .WindowsVersion 7)}}<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
{{end}}
|
||||||
|
{{if (le .WindowsVersion 6)}}<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||||
|
{{end}}
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
</assembly>`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest bufferCoff
|
||||||
|
if err := t.Execute(&manifest, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Coff.AddResource(windowsResourceManifest, 1, &manifest)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *windowsBuilder) embedInfo(v windowsResources) error {
|
||||||
|
page := uint16(1)
|
||||||
|
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo
|
||||||
|
t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo
|
||||||
|
windowsInfoValueFixed{
|
||||||
|
Signature: 0xFEEF04BD,
|
||||||
|
StructVersion: 0x00010000,
|
||||||
|
FileVersion: v.Version,
|
||||||
|
ProductVersion: v.Version,
|
||||||
|
FileFlagMask: 0x3F,
|
||||||
|
FileFlags: 0,
|
||||||
|
FileOS: 0x40004,
|
||||||
|
FileType: 0x1,
|
||||||
|
FileSubType: 0,
|
||||||
|
},
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo
|
||||||
|
newValue(valueText, "StringFileInfo", []io.WriterTo{
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable
|
||||||
|
newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str
|
||||||
|
newValue(valueText, "ProductVersion", v.VersionHuman),
|
||||||
|
newValue(valueText, "FileVersion", v.VersionHuman),
|
||||||
|
newValue(valueText, "FileDescription", v.Name),
|
||||||
|
newValue(valueText, "ProductName", v.Name),
|
||||||
|
// TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...)
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo
|
||||||
|
newValue(valueBinary, "VarFileInfo", []io.WriterTo{
|
||||||
|
// https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str
|
||||||
|
newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`:
|
||||||
|
t.ValueLength = 52
|
||||||
|
|
||||||
|
var verrsrc bufferCoff
|
||||||
|
if _, err := t.WriteTo(&verrsrc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowsInfoValueFixed struct {
|
||||||
|
Signature uint32
|
||||||
|
StructVersion uint32
|
||||||
|
FileVersion [2]uint32
|
||||||
|
ProductVersion [2]uint32
|
||||||
|
FileFlagMask uint32
|
||||||
|
FileFlags uint32
|
||||||
|
FileOS uint32
|
||||||
|
FileType uint32
|
||||||
|
FileSubType uint32
|
||||||
|
FileDate [2]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) {
|
||||||
|
return 0, binary.Write(w, binary.LittleEndian, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowsInfoValue struct {
|
||||||
|
Length uint16
|
||||||
|
ValueLength uint16
|
||||||
|
Type uint16
|
||||||
|
Key []byte
|
||||||
|
Value []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) {
|
||||||
|
// binary.Write doesn't support []byte inside struct.
|
||||||
|
if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err = w.Write(v.Key); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err = w.Write(v.Value); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
valueBinary uint16 = 0
|
||||||
|
valueText uint16 = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
func newValue(valueType uint16, key string, input interface{}) windowsInfoValue {
|
||||||
|
v := windowsInfoValue{
|
||||||
|
Type: valueType,
|
||||||
|
Length: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
padding := func(in []byte) []byte {
|
||||||
|
if l := uint16(len(in)) + v.Length; l%4 != 0 {
|
||||||
|
return append(in, make([]byte, 4-l%4)...)
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Key = padding(utf16Encode(key))
|
||||||
|
v.Length += uint16(len(v.Key))
|
||||||
|
|
||||||
|
switch in := input.(type) {
|
||||||
|
case string:
|
||||||
|
v.Value = padding(utf16Encode(in))
|
||||||
|
v.ValueLength = uint16(len(v.Value) / 2)
|
||||||
|
case []io.WriterTo:
|
||||||
|
var buff bytes.Buffer
|
||||||
|
for k := range in {
|
||||||
|
if _, err := in[k].WriteTo(&buff); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Value = buff.Bytes()
|
||||||
|
default:
|
||||||
|
var buff bytes.Buffer
|
||||||
|
if err := binary.Write(&buff, binary.LittleEndian, in); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
v.ValueLength = uint16(buff.Len())
|
||||||
|
v.Value = buff.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Length += uint16(len(v.Value))
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf16Encode encodes the string to UTF16 with null-termination.
|
||||||
|
func utf16Encode(s string) []byte {
|
||||||
|
b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return append(b, 0x00, 0x00) // null-termination.
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type X11TestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *X11TestDriver) Start(path string) {
|
||||||
|
// First, build the app.
|
||||||
|
bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red")
|
||||||
|
flags := []string{"build", "-tags", "nowayland", "-o=" + bin}
|
||||||
|
if raceEnabled {
|
||||||
|
flags = append(flags, "-race")
|
||||||
|
}
|
||||||
|
flags = append(flags, path)
|
||||||
|
cmd := exec.Command("go", flags...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Fatalf("could not build app: %s:\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
d.Cleanup(wg.Wait)
|
||||||
|
|
||||||
|
d.startServer(&wg, d.width, d.height)
|
||||||
|
|
||||||
|
// Then, start our program on the X server above.
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, bin)
|
||||||
|
cmd.Env = []string{"DISPLAY=" + d.display}
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
d.output = output
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
d.Error(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) {
|
||||||
|
// Pick a random display number between 1 and 100,000. Most machines
|
||||||
|
// will only be using :0, so there's only a 0.001% chance of two
|
||||||
|
// concurrent test runs to run into a conflict.
|
||||||
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1)
|
||||||
|
|
||||||
|
var xprog string
|
||||||
|
xflags := []string{
|
||||||
|
"-wr", // we want a white background; the default is black
|
||||||
|
}
|
||||||
|
if *headless {
|
||||||
|
xprog = "Xvfb" // virtual X server
|
||||||
|
xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height))
|
||||||
|
} else {
|
||||||
|
xprog = "Xephyr" // nested X server as a window
|
||||||
|
xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height))
|
||||||
|
}
|
||||||
|
xflags = append(xflags, d.display)
|
||||||
|
|
||||||
|
d.needPrograms(
|
||||||
|
xprog, // to run the X server
|
||||||
|
"scrot", // to take screenshots
|
||||||
|
"xdotool", // to send input
|
||||||
|
)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, xprog, xflags...)
|
||||||
|
combined := &bytes.Buffer{}
|
||||||
|
cmd.Stdout = combined
|
||||||
|
cmd.Stderr = combined
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
d.Cleanup(func() {
|
||||||
|
// Give it a chance to exit gracefully, cleaning up
|
||||||
|
// after itself. After 10ms, the deferred cancel above
|
||||||
|
// will signal an os.Kill.
|
||||||
|
cmd.Process.Signal(os.Interrupt)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for the X server to be ready. The socket path isn't
|
||||||
|
// terribly portable, but that's okay for now.
|
||||||
|
withRetries(d.T, time.Second, func() error {
|
||||||
|
socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:])
|
||||||
|
_, err := os.Stat(socket)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
// Print all output and error.
|
||||||
|
io.Copy(os.Stdout, combined)
|
||||||
|
d.Error(err)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *X11TestDriver) Screenshot() image.Image {
|
||||||
|
cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout")
|
||||||
|
cmd.Env = []string{"DISPLAY=" + d.display}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *X11TestDriver) xdotool(args ...interface{}) string {
|
||||||
|
d.Helper()
|
||||||
|
strs := make([]string, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
strs[i] = fmt.Sprint(arg)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("xdotool", strs...)
|
||||||
|
cmd.Env = []string{"DISPLAY=" + d.display}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(bytes.TrimSpace(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *X11TestDriver) Click(x, y int) {
|
||||||
|
d.xdotool("mousemove", "--sync", x, y)
|
||||||
|
d.xdotool("click", "1")
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
// Command svg2gio converts SVG files to Gio functions. Only a limited subset of
|
||||||
|
// SVG files are supported.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"go/format"
|
||||||
|
|
||||||
|
"gioui.org/f32"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pkg = flag.String("pkg", "", "Go package")
|
||||||
|
output = flag.String("o", "svg.go", "Output Go file")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
if *pkg == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
args := flag.Args()
|
||||||
|
if err := convertAll(args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Points []float32
|
||||||
|
|
||||||
|
func (p *Points) UnmarshalText(text []byte) error {
|
||||||
|
for {
|
||||||
|
text = bytes.TrimLeft(text, "\t\n")
|
||||||
|
if len(text) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var num []byte
|
||||||
|
end := bytes.IndexAny(text, " ,")
|
||||||
|
if end != -1 {
|
||||||
|
num = text[:end]
|
||||||
|
text = text[end+1:]
|
||||||
|
} else {
|
||||||
|
num = text
|
||||||
|
text = nil
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(string(num), 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*p = append(*p, float32(f))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transform f32.Affine2D
|
||||||
|
|
||||||
|
func (t *Transform) UnmarshalText(text []byte) error {
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")):
|
||||||
|
trans := text[7 : len(text)-1]
|
||||||
|
var p Points
|
||||||
|
if err := p.UnmarshalText(trans); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(p) != 6 {
|
||||||
|
return fmt.Errorf("malformed transform matrix: %q", text)
|
||||||
|
}
|
||||||
|
*t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5]))
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported transform: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fill struct {
|
||||||
|
Transform Transform `xml:"transform,attr"`
|
||||||
|
Fill Color `xml:"fill,attr"`
|
||||||
|
Stroke Color `xml:"stroke,attr"`
|
||||||
|
StrokeLinejoin string `xml:"stroke-linejoin,attr"`
|
||||||
|
StrokeLinecap string `xml:"stroke-linecap,attr"`
|
||||||
|
StrokeWidth float32 `xml:"stroke-width,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color struct {
|
||||||
|
Set bool
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Color) UnmarshalText(text []byte) error {
|
||||||
|
if string(text) == "none" {
|
||||||
|
*c = Color{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(text, []byte("#")) {
|
||||||
|
return fmt.Errorf("invalid color: %q", text)
|
||||||
|
}
|
||||||
|
text = text[1:]
|
||||||
|
i, err := strconv.ParseInt(string(text), 16, 32)
|
||||||
|
// Implied alpha.
|
||||||
|
if len(text) == 6 {
|
||||||
|
i |= 0xff000000
|
||||||
|
}
|
||||||
|
*c = Color{
|
||||||
|
Set: true,
|
||||||
|
Value: int(i),
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertAll(files []string) error {
|
||||||
|
w := new(bytes.Buffer)
|
||||||
|
fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n")
|
||||||
|
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||||
|
fmt.Fprintf(w, "import \"image/color\"\n")
|
||||||
|
fmt.Fprintf(w, "import \"math\"\n")
|
||||||
|
fmt.Fprintf(w, "import \"gioui.org/op\"\n")
|
||||||
|
fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n")
|
||||||
|
fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n")
|
||||||
|
fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n")
|
||||||
|
fmt.Fprintf(w, "var ops op.Ops\n\n")
|
||||||
|
fmt.Fprintf(w, funcs)
|
||||||
|
for _, filename := range files {
|
||||||
|
if err := convert(w, filename); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, err := format.Source(w.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(*output, src, 0o660)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convert(w io.Writer, filename string) error {
|
||||||
|
base := filepath.Base(filename)
|
||||||
|
ext := filepath.Ext(base)
|
||||||
|
name := "Image_" + base[:len(base)-len(ext)]
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "var %s struct {\n", name)
|
||||||
|
fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n")
|
||||||
|
fmt.Fprintf(w, "Call op.CallOp\n\n")
|
||||||
|
fmt.Fprintf(w, "}\n")
|
||||||
|
fmt.Fprintf(w, "func init() {\n")
|
||||||
|
defer fmt.Fprintf(w, "}\n")
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
d := xml.NewDecoder(f)
|
||||||
|
if err := parse(w, d, name); err != nil {
|
||||||
|
line, col := d.InputPos()
|
||||||
|
return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(w io.Writer, d *xml.Decoder, name string) error {
|
||||||
|
for {
|
||||||
|
tok, err := d.Token()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return errors.New("unexpected end of file")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch tok := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if n := tok.Name.Local; n != "svg" {
|
||||||
|
return fmt.Errorf("invalid SVG root: <%s>", n)
|
||||||
|
}
|
||||||
|
if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" {
|
||||||
|
return fmt.Errorf("unsupported SVG namespace: %s", n)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "m := op.Record(&ops)\n")
|
||||||
|
defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name)
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Name.Local == "viewBox" {
|
||||||
|
var p Points
|
||||||
|
if err := p.UnmarshalText([]byte(a.Value)); err != nil {
|
||||||
|
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
|
||||||
|
}
|
||||||
|
if len(p) != 4 {
|
||||||
|
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1])))
|
||||||
|
fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseSVG(w, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func point(p f32.Point) string {
|
||||||
|
return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Poly struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
Points Points `xml:"points,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poly) Path(w io.Writer) error {
|
||||||
|
if len(p.Points) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pen := f32.Pt(p.Points[0], p.Points[1])
|
||||||
|
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen))
|
||||||
|
last := pen
|
||||||
|
for i := 2; i < len(p.Points); i += 2 {
|
||||||
|
last = f32.Pt(p.Points[i], p.Points[i+1])
|
||||||
|
fmt.Fprintf(w, "p.LineTo(%s)\n", point(last))
|
||||||
|
}
|
||||||
|
if p.XMLName.Local == "polygon" && last != pen {
|
||||||
|
fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Path struct {
|
||||||
|
D string `xml:"d,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) Path(w io.Writer) error {
|
||||||
|
return printPathCommands(w, p.D)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Line struct {
|
||||||
|
X1 float32 `xml:"x1,attr"`
|
||||||
|
Y1 float32 `xml:"y1,attr"`
|
||||||
|
X2 float32 `xml:"x2,attr"`
|
||||||
|
Y2 float32 `xml:"y2,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Line) Path(w io.Writer) error {
|
||||||
|
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1)))
|
||||||
|
fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ellipse struct {
|
||||||
|
Cx float32 `xml:"cx,attr"`
|
||||||
|
Cy float32 `xml:"cy,attr"`
|
||||||
|
Rx float32 `xml:"rx,attr"`
|
||||||
|
Ry float32 `xml:"ry,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Ellipse) Path(w io.Writer) error {
|
||||||
|
c := f32.Pt(e.Cx, e.Cy)
|
||||||
|
r := f32.Pt(e.Rx, e.Ry)
|
||||||
|
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rect struct {
|
||||||
|
X float32 `xml:"x,attr"`
|
||||||
|
Y float32 `xml:"y,attr"`
|
||||||
|
Width float32 `xml:"width,attr"`
|
||||||
|
Height float32 `xml:"height,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rect) Path(w io.Writer) error {
|
||||||
|
o := f32.Pt(r.X, r.Y)
|
||||||
|
sz := f32.Pt(r.Width, r.Height)
|
||||||
|
fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Circle struct {
|
||||||
|
Cx float32 `xml:"cx,attr"`
|
||||||
|
Cy float32 `xml:"cy,attr"`
|
||||||
|
R float32 `xml:"r,attr"`
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Circle) Path(w io.Writer) error {
|
||||||
|
center := f32.Pt(c.Cx, c.Cy)
|
||||||
|
r := f32.Pt(c.R, c.R)
|
||||||
|
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSVG(w io.Writer, d *xml.Decoder) error {
|
||||||
|
for {
|
||||||
|
tok, err := d.Token()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return errors.New("unexpected end of <svg> element")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var start xml.StartElement
|
||||||
|
switch tok := tok.(type) {
|
||||||
|
case xml.EndElement:
|
||||||
|
return nil
|
||||||
|
case xml.StartElement:
|
||||||
|
start = tok
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var elem interface {
|
||||||
|
Path(w io.Writer) error
|
||||||
|
}
|
||||||
|
var fill *Fill
|
||||||
|
switch n := start.Name.Local; n {
|
||||||
|
case "g":
|
||||||
|
// Flatten groups.
|
||||||
|
if err := parseSVG(w, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case "title":
|
||||||
|
d.Skip()
|
||||||
|
continue
|
||||||
|
case "polygon", "polyline":
|
||||||
|
p := new(Poly)
|
||||||
|
elem = p
|
||||||
|
fill = &p.Fill
|
||||||
|
case "path":
|
||||||
|
p := new(Path)
|
||||||
|
elem = p
|
||||||
|
fill = &p.Fill
|
||||||
|
case "line":
|
||||||
|
l := new(Line)
|
||||||
|
elem = l
|
||||||
|
fill = &l.Fill
|
||||||
|
case "ellipse":
|
||||||
|
e := new(Ellipse)
|
||||||
|
elem = e
|
||||||
|
fill = &e.Fill
|
||||||
|
case "rect":
|
||||||
|
r := new(Rect)
|
||||||
|
elem = r
|
||||||
|
fill = &r.Fill
|
||||||
|
case "circle":
|
||||||
|
c := new(Circle)
|
||||||
|
elem = c
|
||||||
|
fill = &c.Fill
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported tag: <%s>", n)
|
||||||
|
}
|
||||||
|
if err := d.DecodeElement(elem, &start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fill.Fill.Set && !fill.Stroke.Set {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "{\n")
|
||||||
|
trans := f32.Affine2D(fill.Transform)
|
||||||
|
if trans != (f32.Affine2D{}) {
|
||||||
|
sx, hx, ox, sy, hy, oy := trans.Elems()
|
||||||
|
fmt.Fprintf(w, "t := op.Affine(f32.NewAffine2D(%g, %g, %g, %g, %g, %g)).Push(&ops)\n", sx, hx, ox, sy, hy, oy)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "var p clip.Path\n")
|
||||||
|
fmt.Fprintf(w, "p.Begin(&ops)\n")
|
||||||
|
if err := elem.Path(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "spec := p.End()\n")
|
||||||
|
if fill.Fill.Set {
|
||||||
|
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Outline{Path: spec}.Op())\n", fill.Fill.Value)
|
||||||
|
}
|
||||||
|
if fill.Stroke.Set {
|
||||||
|
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Stroke{Width: %g, Path: spec}.Op())\n", fill.Stroke.Value, fill.StrokeWidth)
|
||||||
|
}
|
||||||
|
if trans != (f32.Affine2D{}) {
|
||||||
|
fmt.Fprintf(w, "t.Pop()\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPathCommands(w io.Writer, cmds string) error {
|
||||||
|
moveTo := func(p f32.Point) {
|
||||||
|
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(p))
|
||||||
|
}
|
||||||
|
lineTo := func(p f32.Point) {
|
||||||
|
fmt.Fprintf(w, "p.LineTo(%s)\n", point(p))
|
||||||
|
}
|
||||||
|
cubeTo := func(p0, p1, p2 f32.Point) {
|
||||||
|
fmt.Fprintf(w, "p.CubeTo(%s, %s, %s)\n", point(p0), point(p1), point(p2))
|
||||||
|
}
|
||||||
|
cmds = strings.TrimSpace(cmds)
|
||||||
|
var pen f32.Point
|
||||||
|
initPoint := pen
|
||||||
|
ctrl2 := pen
|
||||||
|
for {
|
||||||
|
cmds = strings.TrimLeft(cmds, " ,\t\n")
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
orig := cmds
|
||||||
|
op := rune(cmds[0])
|
||||||
|
cmds = cmds[1:]
|
||||||
|
switch op {
|
||||||
|
case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's':
|
||||||
|
case 'Z', 'z':
|
||||||
|
if pen != initPoint {
|
||||||
|
lineTo(initPoint)
|
||||||
|
pen = initPoint
|
||||||
|
}
|
||||||
|
ctrl2 = initPoint
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown <path> command %s in %q", string(op), orig)
|
||||||
|
}
|
||||||
|
var coords []float64
|
||||||
|
for {
|
||||||
|
cmds = strings.TrimLeft(cmds, " ,\t\n")
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n, x, ok := parseFloat(cmds)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmds = cmds[n:]
|
||||||
|
coords = append(coords, x)
|
||||||
|
}
|
||||||
|
rel := unicode.IsLower(op)
|
||||||
|
newPen := pen
|
||||||
|
switch unicode.ToLower(op) {
|
||||||
|
case 'h':
|
||||||
|
for _, x := range coords {
|
||||||
|
p := f32.Pt(float32(x), pen.Y)
|
||||||
|
if rel {
|
||||||
|
p.X += pen.X
|
||||||
|
}
|
||||||
|
lineTo(p)
|
||||||
|
newPen = p
|
||||||
|
}
|
||||||
|
pen = newPen
|
||||||
|
ctrl2 = newPen
|
||||||
|
continue
|
||||||
|
case 'v':
|
||||||
|
for _, y := range coords {
|
||||||
|
p := f32.Pt(pen.X, float32(y))
|
||||||
|
if rel {
|
||||||
|
p.Y += pen.Y
|
||||||
|
}
|
||||||
|
lineTo(p)
|
||||||
|
newPen = p
|
||||||
|
}
|
||||||
|
pen = newPen
|
||||||
|
ctrl2 = newPen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(coords)%2 != 0 {
|
||||||
|
return fmt.Errorf("odd number of coordinates in <path> data: %q", orig)
|
||||||
|
}
|
||||||
|
var off f32.Point
|
||||||
|
if rel {
|
||||||
|
// Relative command.
|
||||||
|
off = pen
|
||||||
|
} else {
|
||||||
|
off = f32.Pt(0, 0)
|
||||||
|
}
|
||||||
|
var points []f32.Point
|
||||||
|
for i := 0; i < len(coords); i += 2 {
|
||||||
|
p := f32.Pt(float32(coords[i]), float32(coords[i+1]))
|
||||||
|
p = p.Add(off)
|
||||||
|
points = append(points, p)
|
||||||
|
}
|
||||||
|
newCtrl2 := ctrl2
|
||||||
|
switch op := unicode.ToLower(op); op {
|
||||||
|
case 'm', 'l':
|
||||||
|
sop := moveTo
|
||||||
|
if op == 'l' {
|
||||||
|
sop = lineTo
|
||||||
|
}
|
||||||
|
for _, p := range points {
|
||||||
|
sop(p)
|
||||||
|
newPen = p
|
||||||
|
}
|
||||||
|
if op == 'm' {
|
||||||
|
initPoint = newPen
|
||||||
|
}
|
||||||
|
case 'c':
|
||||||
|
for i := 0; i < len(points); i += 3 {
|
||||||
|
p1, p2, p3 := points[i], points[i+1], points[i+2]
|
||||||
|
cubeTo(p1, p2, p3)
|
||||||
|
newPen = p3
|
||||||
|
newCtrl2 = p2
|
||||||
|
}
|
||||||
|
case 's':
|
||||||
|
for i := 0; i < len(points); i += 2 {
|
||||||
|
p2, p3 := points[i], points[i+1]
|
||||||
|
// Compute p1 by reflecting p2 on to the line that contains pen and p2.
|
||||||
|
p1 := pen.Mul(2).Sub(ctrl2)
|
||||||
|
cubeTo(p1, p2, p3)
|
||||||
|
newPen = p3
|
||||||
|
newCtrl2 = p2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pen = newPen
|
||||||
|
ctrl2 = newCtrl2
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) (int, float64, bool) {
|
||||||
|
n := 0
|
||||||
|
if len(s) > 0 && s[0] == '-' {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
for ; n < len(s); n++ {
|
||||||
|
if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s[:n], 64)
|
||||||
|
return n, f, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcs = `
|
||||||
|
func argb(c uint32) color.NRGBA {
|
||||||
|
return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rect(p *clip.Path, origin, size f32.Point) {
|
||||||
|
p.MoveTo(origin)
|
||||||
|
p.LineTo(origin.Add(f32.Pt(size.X, 0)))
|
||||||
|
p.LineTo(origin.Add(size))
|
||||||
|
p.LineTo(origin.Add(f32.Pt(0, size.Y)))
|
||||||
|
p.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ellipse(p *clip.Path, center, radius f32.Point) {
|
||||||
|
r := radius.X
|
||||||
|
// We'll model the ellipse as a circle scaled in the Y
|
||||||
|
// direction.
|
||||||
|
scale := radius.Y / r
|
||||||
|
|
||||||
|
// https://pomax.github.io/bezierinfo/#circles_cubic.
|
||||||
|
const q = 4 * (math.Sqrt2 - 1) / 3
|
||||||
|
|
||||||
|
curve := r * q
|
||||||
|
top := f32.Point{X: center.X, Y: center.Y - r*scale}
|
||||||
|
|
||||||
|
p.MoveTo(top)
|
||||||
|
p.CubeTo(
|
||||||
|
f32.Point{X: center.X + curve, Y: center.Y - r*scale},
|
||||||
|
f32.Point{X: center.X + r, Y: center.Y - curve*scale},
|
||||||
|
f32.Point{X: center.X + r, Y: center.Y},
|
||||||
|
)
|
||||||
|
p.CubeTo(
|
||||||
|
f32.Point{X: center.X + r, Y: center.Y + curve*scale},
|
||||||
|
f32.Point{X: center.X + curve, Y: center.Y + r*scale},
|
||||||
|
f32.Point{X: center.X, Y: center.Y + r*scale},
|
||||||
|
)
|
||||||
|
p.CubeTo(
|
||||||
|
f32.Point{X: center.X - curve, Y: center.Y + r*scale},
|
||||||
|
f32.Point{X: center.X - r, Y: center.Y + curve*scale},
|
||||||
|
f32.Point{X: center.X - r, Y: center.Y},
|
||||||
|
)
|
||||||
|
p.CubeTo(
|
||||||
|
f32.Point{X: center.X - r, Y: center.Y - curve*scale},
|
||||||
|
f32.Point{X: center.X - curve, Y: center.Y - r*scale},
|
||||||
|
top,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -19,26 +19,49 @@ type focusAppearance struct {
|
|||||||
MinHeight int
|
MinHeight int
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
|
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
appearance := focusAppearance{
|
appearance := focusAppearance{
|
||||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||||
OutlineColor: color.NRGBA{A: 0},
|
OutlineColor: color.NRGBA{A: 0},
|
||||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||||
MinHeight: metric.Dp(unit.Dp(44)),
|
MinHeight: metric.Dp(unit.Dp(44)),
|
||||||
}
|
}
|
||||||
|
if prefs.DisplayDensity == displayDensityComfortable {
|
||||||
|
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||||
|
}
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||||
|
}
|
||||||
if focused {
|
if focused {
|
||||||
appearance.BorderColor = accentColor
|
appearance.BorderColor = accentColor
|
||||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||||
|
}
|
||||||
|
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return appearance
|
return appearance
|
||||||
}
|
}
|
||||||
|
|
||||||
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
|
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
text = accentColor
|
text = accentColor
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||||
|
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
}
|
||||||
if focused {
|
if focused {
|
||||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||||
|
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return background, text
|
return background, text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.mode != "phone" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
||||||
|
if u.mode == "phone" {
|
||||||
|
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
||||||
|
}
|
||||||
|
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
||||||
|
width := gtx.Dp(unit.Dp(widthDP))
|
||||||
|
height := gtx.Dp(unit.Dp(heightDP))
|
||||||
|
if width > gtx.Constraints.Max.X {
|
||||||
|
width = gtx.Constraints.Max.X
|
||||||
|
}
|
||||||
|
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
||||||
|
height = gtx.Constraints.Max.Y
|
||||||
|
}
|
||||||
|
img := widget.Image{
|
||||||
|
Src: src,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.W,
|
||||||
|
Scale: 1.0 / gtx.Metric.PxPerDp,
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min = image.Point{}
|
||||||
|
gtx.Constraints.Max = image.Pt(width, height)
|
||||||
|
return img.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ func (u *ui) attachmentInput() (string, []byte, error) {
|
|||||||
|
|
||||||
func (u *ui) loadSelectedEntryIntoEditor() {
|
func (u *ui) loadSelectedEntryIntoEditor() {
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
|
u.clearGeneratedPasswordDraft()
|
||||||
u.selectedHistoryIndex = -1
|
u.selectedHistoryIndex = -1
|
||||||
u.historyIndex.SetText("")
|
u.historyIndex.SetText("")
|
||||||
|
|
||||||
@@ -175,6 +176,7 @@ func (u *ui) saveEntryAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
|
u.clearGeneratedPasswordDraft()
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,6 +231,7 @@ func (u *ui) saveTemplateAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
|
u.clearGeneratedPasswordDraft()
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -251,6 +254,14 @@ func (u *ui) createGroupAction() error {
|
|||||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) moveCurrentGroupAction() error {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
|
if len(u.displayPath()) == 0 {
|
||||||
|
return fmt.Errorf("no current group selected")
|
||||||
|
}
|
||||||
|
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) renameGroupAction() error {
|
func (u *ui) renameGroupAction() error {
|
||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
@@ -400,6 +411,7 @@ func (u *ui) generatePasswordAction() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.entryPassword.SetText(password)
|
u.entryPassword.SetText(password)
|
||||||
|
u.generatedPasswordDraft = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,3 +493,27 @@ func marshalFields(fields map[string]string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearGeneratedPasswordDraft() {
|
||||||
|
u.generatedPasswordDraft = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) attachmentActionSummary() string {
|
||||||
|
items := u.selectedAttachmentItems()
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "No attachments yet. Add one below to store a file with this entry."
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(u.attachmentName.Text())
|
||||||
|
if name == "" {
|
||||||
|
return "Select an attachment above, then replace it, export it, or remove it below."
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Name == name {
|
||||||
|
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if u.isVaultLocked() && name == key.NameReturn {
|
if u.isVaultLocked() && name == key.NameReturn {
|
||||||
u.runAction("unlock vault", u.unlockAction)
|
u.startUnlockAction()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
||||||
if u.lifecycleMode == "remote" {
|
if u.lifecycleMode == "remote" {
|
||||||
u.runAction("open remote vault", u.openRemoteAction)
|
u.startOpenRemoteAction()
|
||||||
} else {
|
} else {
|
||||||
u.runAction("open vault", u.openVaultAction)
|
u.startOpenVaultAction()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"image/color"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
displayDensityDense = "dense"
|
||||||
|
displayDensityComfortable = "comfortable"
|
||||||
|
|
||||||
|
contrastStandard = "standard"
|
||||||
|
contrastHigh = "high"
|
||||||
|
|
||||||
|
keyboardFocusStandard = "standard"
|
||||||
|
keyboardFocusProminent = "prominent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accessibilityPreferences struct {
|
||||||
|
DisplayDensity string
|
||||||
|
Contrast string
|
||||||
|
ReducedMotion bool
|
||||||
|
KeyboardFocus string
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsFile struct {
|
||||||
|
Sync syncSettings `json:"sync,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncSettings struct {
|
||||||
|
SourceDefault string `json:"sourceDefault,omitempty"`
|
||||||
|
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncSettingsDraft struct {
|
||||||
|
SourceDefault syncSourceMode
|
||||||
|
DirectionDefault syncDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsDraft struct {
|
||||||
|
Accessibility accessibilityPreferences
|
||||||
|
Sync syncSettingsDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacySyncPreferences struct {
|
||||||
|
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
|
||||||
|
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type choiceSpec struct {
|
||||||
|
Click *widget.Clickable
|
||||||
|
Label string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||||
|
return accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityForDenseLayout(true),
|
||||||
|
Contrast: contrastStandard,
|
||||||
|
KeyboardFocus: keyboardFocusStandard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayDensityForDenseLayout(dense bool) string {
|
||||||
|
if dense {
|
||||||
|
return displayDensityDense
|
||||||
|
}
|
||||||
|
return displayDensityComfortable
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||||
|
normalized := defaultAccessibilityPreferences()
|
||||||
|
switch prefs.DisplayDensity {
|
||||||
|
case displayDensityDense, displayDensityComfortable:
|
||||||
|
normalized.DisplayDensity = prefs.DisplayDensity
|
||||||
|
}
|
||||||
|
switch prefs.Contrast {
|
||||||
|
case contrastStandard, contrastHigh:
|
||||||
|
normalized.Contrast = prefs.Contrast
|
||||||
|
}
|
||||||
|
switch prefs.KeyboardFocus {
|
||||||
|
case keyboardFocusStandard, keyboardFocusProminent:
|
||||||
|
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||||
|
}
|
||||||
|
normalized.ReducedMotion = prefs.ReducedMotion
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||||
|
normalized := normalizeAccessibilityPreferences(prefs)
|
||||||
|
u.denseLayout = normalized.DisplayDensity == displayDensityDense
|
||||||
|
u.accessibilityPrefs = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadSettingsDraft() {
|
||||||
|
u.settingsDraft = settingsDraft{
|
||||||
|
Accessibility: accessibilityPreferences{
|
||||||
|
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
|
||||||
|
Contrast: u.accessibilityPrefs.Contrast,
|
||||||
|
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
||||||
|
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
||||||
|
},
|
||||||
|
Sync: syncSettingsDraft{
|
||||||
|
SourceDefault: u.syncDefaultSourceMode,
|
||||||
|
DirectionDefault: u.syncDefaultDirection,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveSecuritySettingsAction() error {
|
||||||
|
if err := u.applySecuritySettingsLive(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.securityDialogOpen = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applySecuritySettingsLive() error {
|
||||||
|
settings := vault.SecuritySettings{
|
||||||
|
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||||
|
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||||
|
}
|
||||||
|
if err := u.state.ConfigureSecurity(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||||
|
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||||
|
}
|
||||||
|
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||||
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||||
|
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||||
|
u.applySettingsFormToPreferences()
|
||||||
|
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||||
|
u.saveSettings()
|
||||||
|
u.saveUIPreferences()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadSettings() {
|
||||||
|
u.syncDefaultSourceMode = syncSourceLocal
|
||||||
|
u.syncDefaultDirection = syncDirectionPull
|
||||||
|
|
||||||
|
if strings.TrimSpace(u.settingsPath) != "" {
|
||||||
|
content, err := os.ReadFile(u.settingsPath)
|
||||||
|
if err == nil {
|
||||||
|
var settings settingsFile
|
||||||
|
if json.Unmarshal(content, &settings) == nil {
|
||||||
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||||
|
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u.loadLegacySyncDefaultsFromUIPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
|
||||||
|
if strings.TrimSpace(u.uiPreferencesPath) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(u.uiPreferencesPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var prefs legacySyncPreferences
|
||||||
|
if err := json.Unmarshal(content, &prefs); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
|
||||||
|
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveSettings() {
|
||||||
|
if strings.TrimSpace(u.settingsPath) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := json.MarshalIndent(settingsFile{
|
||||||
|
Sync: syncSettings{
|
||||||
|
SourceDefault: string(u.syncDefaultSourceMode),
|
||||||
|
DirectionDefault: string(u.syncDefaultDirection),
|
||||||
|
},
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) showStatusMessage(message string) {
|
||||||
|
u.state.StatusMessage = message
|
||||||
|
if u.accessibilityPrefs.ReducedMotion {
|
||||||
|
u.statusExpiresAt = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
|
||||||
|
return sectionCard(gtx, u.theme, title, detail, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
|
||||||
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(th, unit.Sp(12), title)
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(th, unit.Sp(13), body)
|
||||||
|
lbl.Color = th.Palette.Fg
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
|
||||||
|
children := make([]layout.FlexChild, 0, len(choices)*2)
|
||||||
|
for i, choice := range choices {
|
||||||
|
if i > 0 {
|
||||||
|
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
|
||||||
|
}
|
||||||
|
current := choice
|
||||||
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listRowColors struct {
|
||||||
|
Title color.NRGBA
|
||||||
|
Meta color.NRGBA
|
||||||
|
Secondary color.NRGBA
|
||||||
|
Divider color.NRGBA
|
||||||
|
Fill color.NRGBA
|
||||||
|
Edge color.NRGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
|
||||||
|
colors := listRowColors{
|
||||||
|
Title: accentColor,
|
||||||
|
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
|
||||||
|
Secondary: mutedColor,
|
||||||
|
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
|
||||||
|
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
|
||||||
|
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
|
||||||
|
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
|
||||||
|
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
|
||||||
|
}
|
||||||
|
if recycleBin {
|
||||||
|
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
|
||||||
|
}
|
||||||
|
if focused && !selected {
|
||||||
|
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
|
||||||
|
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
|
||||||
|
}
|
||||||
|
if u.accessibilityPrefs.Contrast == contrastHigh {
|
||||||
|
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
|
||||||
|
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
|
||||||
|
if focused || selected {
|
||||||
|
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
|
||||||
|
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
|
||||||
|
}
|
||||||
|
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
|
||||||
|
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
|
||||||
|
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
|
||||||
|
}
|
||||||
|
return colors
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
|||||||
key.Filter{Name: key.NameUpArrow},
|
key.Filter{Name: key.NameUpArrow},
|
||||||
key.Filter{Name: key.NameDownArrow},
|
key.Filter{Name: key.NameDownArrow},
|
||||||
key.Filter{Name: key.NameReturn},
|
key.Filter{Name: key.NameReturn},
|
||||||
|
key.Filter{Name: key.NameBack},
|
||||||
|
key.Filter{Name: key.NameEscape},
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
@@ -48,6 +50,9 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.handleKeyPress(ke.Name, ke.Modifiers)
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||||
|
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
||||||
|
_ = u.handlePhoneBack()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,6 +96,27 @@ func (m Model) EntriesInPath(path []string) []Entry {
|
|||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) EntriesUnderPath(path []string) []Entry {
|
||||||
|
var entries []Entry
|
||||||
|
for _, entry := range m.Entries {
|
||||||
|
if !hasPathPrefix(entry.Path, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
slices.SortFunc(entries, func(a, b Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) Search(query string) []SearchResult {
|
func (m Model) Search(query string) []SearchResult {
|
||||||
query = strings.TrimSpace(strings.ToLower(query))
|
query = strings.TrimSpace(strings.ToLower(query))
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@@ -256,13 +277,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) CreateGroup(parent []string, name string) {
|
func (m *Model) CreateGroup(parent []string, name string) {
|
||||||
groupPath := append(append([]string(nil), parent...), name)
|
groupPath := append([]string(nil), parent...)
|
||||||
for _, existing := range m.Groups {
|
for _, part := range splitGroupPath(name) {
|
||||||
if slices.Equal(existing, groupPath) {
|
groupPath = append(groupPath, part)
|
||||||
return
|
if groupPathExists(m.Groups, groupPath) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
|
||||||
}
|
}
|
||||||
m.Groups = append(m.Groups, groupPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) RenameGroup(path []string, newName string) error {
|
func (m *Model) RenameGroup(path []string, newName string) error {
|
||||||
@@ -310,6 +332,47 @@ func (m *Model) MoveEntry(id string, path []string) error {
|
|||||||
return ErrEntryNotFound
|
return ErrEntryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) MoveGroup(path, parent []string) error {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return ErrEntryNotFound
|
||||||
|
}
|
||||||
|
if hasPathPrefix(parent, path) {
|
||||||
|
return ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
groupName := path[len(path)-1]
|
||||||
|
newPath := append(append([]string(nil), parent...), groupName)
|
||||||
|
moved := false
|
||||||
|
for i := range m.Entries {
|
||||||
|
if !hasPathPrefix(m.Entries[i].Path, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
for i := range m.Templates {
|
||||||
|
if !hasPathPrefix(m.Templates[i].Path, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
for i := range m.Groups {
|
||||||
|
if !hasPathPrefix(m.Groups[i], path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
return ErrEntryNotFound
|
||||||
|
}
|
||||||
|
if !groupPathExists(m.Groups, newPath) {
|
||||||
|
m.Groups = append(m.Groups, append([]string(nil), newPath...))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) MoveTemplate(id string, path []string) error {
|
func (m *Model) MoveTemplate(id string, path []string) error {
|
||||||
for i := range m.Templates {
|
for i := range m.Templates {
|
||||||
if m.Templates[i].ID != id {
|
if m.Templates[i].ID != id {
|
||||||
@@ -349,6 +412,27 @@ func hasPathPrefix(path, prefix []string) bool {
|
|||||||
return slices.Equal(path[:len(prefix)], prefix)
|
return slices.Equal(path[:len(prefix)], prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitGroupPath(name string) []string {
|
||||||
|
var parts []string
|
||||||
|
for _, part := range strings.Split(name, "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupPathExists(groups [][]string, path []string) bool {
|
||||||
|
for _, existing := range groups {
|
||||||
|
if slices.Equal(existing, path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func mergeEntryTemplate(template, overrides Entry) Entry {
|
func mergeEntryTemplate(template, overrides Entry) Entry {
|
||||||
entry := cloneEntry(template)
|
entry := cloneEntry(template)
|
||||||
|
|
||||||
|
|||||||
@@ -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,30 +31,43 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
got := model.EntriesUnderPath([]string{"Crew"})
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,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 {
|
||||||
@@ -121,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,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"}},
|
||||||
@@ -209,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"},
|
||||||
},
|
},
|
||||||
@@ -225,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 {
|
||||||
@@ -237,29 +250,45 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
|
||||||
|
|
||||||
|
got := model.ChildGroups([]string{"Crew"})
|
||||||
|
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
|
||||||
|
}
|
||||||
|
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
|
||||||
|
if !slices.Equal(got, []string{"Prod"}) {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,15 +305,37 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
|
||||||
|
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
|
||||||
|
t.Fatalf("MoveGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := model.EntriesInPath([]string{"Tricia", "Internet"})
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||||
|
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
|
||||||
|
}
|
||||||
|
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
|
||||||
|
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
|
||||||
|
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/tobischo/gokeepasslib/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SecuritySettings struct {
|
||||||
|
Cipher string
|
||||||
|
KDF string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
CipherAES256 = "aes256"
|
||||||
|
CipherChaCha20 = "chacha20"
|
||||||
|
KDFAES = "aes-kdf"
|
||||||
|
KDFArgon2 = "argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
|
||||||
|
return []string{CipherAES256, CipherChaCha20}, []string{KDFAES, KDFArgon2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectSecuritySettings(config *KDBXConfig) SecuritySettings {
|
||||||
|
settings := SecuritySettings{
|
||||||
|
Cipher: CipherChaCha20,
|
||||||
|
KDF: KDFArgon2,
|
||||||
|
}
|
||||||
|
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
if slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||||
|
settings.Cipher = CipherAES256
|
||||||
|
}
|
||||||
|
if config.Header.FileHeaders.KdfParameters != nil && slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||||
|
settings.KDF = KDFAES
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSecurityConfig(settings SecuritySettings) (*KDBXConfig, error) {
|
||||||
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
|
config := &KDBXConfig{
|
||||||
|
Header: cloneHeader(db.Header),
|
||||||
|
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
|
||||||
|
}
|
||||||
|
return ApplySecuritySettings(config, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplySecuritySettings(config *KDBXConfig, settings SecuritySettings) (*KDBXConfig, error) {
|
||||||
|
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||||
|
return NewSecurityConfig(settings)
|
||||||
|
}
|
||||||
|
out := &KDBXConfig{
|
||||||
|
Header: cloneHeader(config.Header),
|
||||||
|
InnerHeader: cloneInnerHeader(config.InnerHeader),
|
||||||
|
}
|
||||||
|
if out.Header.FileHeaders.KdfParameters == nil {
|
||||||
|
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
|
out.Header.FileHeaders.KdfParameters = cloneHeader(defaults.Header).FileHeaders.KdfParameters
|
||||||
|
}
|
||||||
|
switch settings.Cipher {
|
||||||
|
case "", CipherChaCha20:
|
||||||
|
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherChaCha20)
|
||||||
|
out.Header.FileHeaders.EncryptionIV = randomBytes(12)
|
||||||
|
case CipherAES256:
|
||||||
|
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherAES)
|
||||||
|
out.Header.FileHeaders.EncryptionIV = randomBytes(16)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported cipher %q", settings.Cipher)
|
||||||
|
}
|
||||||
|
|
||||||
|
var salt [32]byte
|
||||||
|
copy(salt[:], randomBytes(32))
|
||||||
|
switch settings.KDF {
|
||||||
|
case "", KDFArgon2:
|
||||||
|
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
|
kdf := defaults.Header.FileHeaders.KdfParameters
|
||||||
|
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||||
|
UUID: slices.Clone(gokeepasslib.KdfArgon2),
|
||||||
|
Rounds: kdf.Rounds,
|
||||||
|
Salt: salt,
|
||||||
|
Parallelism: kdf.Parallelism,
|
||||||
|
Memory: kdf.Memory,
|
||||||
|
Iterations: kdf.Iterations,
|
||||||
|
Version: kdf.Version,
|
||||||
|
}
|
||||||
|
case KDFAES:
|
||||||
|
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||||
|
UUID: slices.Clone(gokeepasslib.KdfAES4),
|
||||||
|
Rounds: 6000,
|
||||||
|
Salt: salt,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported KDF %q", settings.KDF)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tobischo/gokeepasslib/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSecurityConfigCreatesRequestedCipherAndKDF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||||
|
t.Fatalf("CipherID = %x, want %x", config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES)
|
||||||
|
}
|
||||||
|
if !slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||||
|
t.Fatalf("KDF UUID = %x, want %x", config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySecuritySettingsPreservesRequestedChoicesAcrossSave(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherChaCha20, KDF: KDFArgon2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
config, err = ApplySecuritySettings(config, SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplySecuritySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBXWithConfigAndKey(&encoded, Model{}, MasterKey{Password: "correct horse battery staple"}, config); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithConfigAndKey() error = %v", err)
|
||||||
|
}
|
||||||
|
_, reloadedConfig, err := LoadKDBXWithConfig(bytes.NewReader(encoded.Bytes()), MasterKey{Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBXWithConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
got := DetectSecuritySettings(reloadedConfig)
|
||||||
|
if got.Cipher != CipherAES256 || got.KDF != KDFAES {
|
||||||
|
t.Fatalf("DetectSecuritySettings() = %#v, want aes256/aes-kdf", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||