Compare commits
119 Commits
v0.2.0
..
d8db9f14b3
| Author | SHA1 | Date | |
|---|---|---|---|
| d8db9f14b3 | |||
| d1485a66c1 | |||
| cb1447e554 | |||
| 315988134b | |||
| 6293eca072 | |||
| 3510bcc3b6 | |||
| 5c56abf31b | |||
| 139ea12317 | |||
| fddf864c42 | |||
| 6eee43c1df | |||
| 18d57a2f8e | |||
| b4bcceab31 | |||
| 85f07ded39 | |||
| 2fded8a403 | |||
| 23853c479a | |||
| 54ec195e61 | |||
| 1675811aa3 | |||
| 14f4b31cf6 | |||
| 1148eb819f | |||
| 1ec373821d | |||
| 3e03d4669a | |||
| b9ecd0ae70 | |||
| 69aa72ee3b | |||
| fbd364f01d | |||
| 8be0aad14d | |||
| eef209ae43 | |||
| 322dc84917 | |||
| 3d53ff8e95 | |||
| 0a6b7fe5c0 | |||
| 82bfd694b6 | |||
| d9c2d48ff5 | |||
| 8495beb8dc | |||
| 3e471366ce | |||
| f9b58367da | |||
| 20f84ac58e | |||
| 2db8a71704 | |||
| b7a4742ee6 | |||
| 9afddd7a93 | |||
| 84c188129e | |||
| 980d30f6c2 | |||
| e6b33134c9 | |||
| a269217c60 | |||
| 722d2eefa0 | |||
| dba0bf1f2c | |||
| 47942014d7 | |||
| ed2118223f | |||
| 9e30f654b2 | |||
| 1ecad1fae9 | |||
| fc34508689 | |||
| 9dea7a4a34 | |||
| e5904dd224 | |||
| da769a9728 | |||
| f47682f3a1 | |||
| 9dcc5f1d6f | |||
| ee9b00bd85 | |||
| 8ce924e08f | |||
| bda28eef4b | |||
| bf56e38bc5 | |||
| d137018d3a | |||
| fcc0291dd9 | |||
| cc127a013e | |||
| b2f1d9a66d | |||
| b391cea295 | |||
| fba135ff09 | |||
| 71b383114d | |||
| 75dc6453f7 | |||
| 72b413a11a | |||
| 75002a4c47 | |||
| e98256709d | |||
| 5e15ad3265 | |||
| d492743eb1 | |||
| 4a629c16bd | |||
| 62fc343ecf | |||
| 1e51eff76e | |||
| 4509634a15 | |||
| 47bbbdb05e | |||
| 4fbaee3970 | |||
| d2b49ca0ad | |||
| fedeab2fc1 | |||
| b56401b5c6 | |||
| 01559a3a2b | |||
| 16dc1de3c8 | |||
| bd9674a1f5 | |||
| e2e38a97a0 | |||
| 34a6e0150e | |||
| ec063ac81f | |||
| ac5b6894cf | |||
| d6bc213474 | |||
| 706a72e135 | |||
| f8111f626f | |||
| 0bfa30de91 | |||
| 24dcf9c8ad | |||
| ea5c8b3c31 | |||
| c355eebed5 | |||
| 7651b3b237 | |||
| da942b41d7 | |||
| 1c0cd20bcc | |||
| cffe05af82 | |||
| 8deccced9e | |||
| 4fe912b41f | |||
| 55ca6352b4 | |||
| 0cfccb58d6 | |||
| b043ecdc83 | |||
| d3be07f252 | |||
| 0f30c5b1c5 | |||
| 985150c36f | |||
| f39bdcd3be | |||
| 48ffa78fa2 | |||
| 1349fe6e38 | |||
| 19ccab1d8d | |||
| ae1921506d | |||
| 1f51904567 | |||
| ef867adfa1 | |||
| 5eb2068d3e | |||
| 77407e0510 | |||
| dd0b0b6f6e | |||
| 566256ab79 | |||
| a4b3d5c1e1 | |||
| ce01c47256 |
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: keepassgo-apk-test
|
||||
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
|
||||
---
|
||||
|
||||
# KeePassGO APK Test
|
||||
|
||||
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- Building `build/keepassgo.apk`.
|
||||
- Installing or launching KeePassGO in the Android emulator.
|
||||
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
|
||||
- Checking whether a Gio or Android toolchain change broke runtime behavior.
|
||||
|
||||
## Known Working Environment
|
||||
|
||||
- Preferred emulator AVD: `KeepassGoAPI35`
|
||||
- Package: `org.julianfamily.keepassgo`
|
||||
- Activity: `org.gioui.GioActivity`
|
||||
- Local APK build defaults:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
||||
`ANDROID_NDK_ROOT=/opt/android-ndk`
|
||||
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
|
||||
- CI APK build uses:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`
|
||||
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
|
||||
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
|
||||
|
||||
## Known Regression
|
||||
|
||||
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
|
||||
- `gioui.org v0.8.0` rendered correctly in the same environment.
|
||||
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Do not clobber the user’s real KeePassGO state while testing.
|
||||
- For host-side validation, use isolated state such as:
|
||||
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
|
||||
- Prefer sanitized demo or test vaults when seeding emulator tests.
|
||||
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the user’s existing recent-vault state.
|
||||
|
||||
## Build Workflow
|
||||
|
||||
1. Verify the JDK/SDK paths match the known working environment.
|
||||
2. Build with `make apk`.
|
||||
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
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
name: keepassgo-ship-it
|
||||
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
||||
---
|
||||
|
||||
# KeePassGO Ship It
|
||||
|
||||
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
||||
|
||||
Use it together with:
|
||||
- `android-emulator-debug` for emulator and `adb` mechanics
|
||||
- `keepass-credentials` for Nextcloud credentials
|
||||
- `public-repo-sanitization` before the commit/push step
|
||||
|
||||
## Meaning Of `ship it`
|
||||
|
||||
When the user says `ship it`, do all of this unless they narrow the scope:
|
||||
|
||||
1. Commit the relevant KeePassGO source changes first.
|
||||
2. Build and install the Arch package from that committed source.
|
||||
3. Build the Android APK from that same committed source.
|
||||
4. Zip the APK.
|
||||
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
||||
6. Install the rebuilt APK in the emulator.
|
||||
7. Launch the rebuilt app in the emulator.
|
||||
8. Open a controlled demo vault in the emulator.
|
||||
|
||||
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
||||
|
||||
## Required Sequence
|
||||
|
||||
### 1. Commit First
|
||||
|
||||
- Make sure the worktree state intended for shipping is committed before building.
|
||||
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
||||
- Before the commit or push, run the public-repo sanitization checks.
|
||||
|
||||
### 2. Build And Install The Arch Package
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
make archlinux-pkgbuild
|
||||
cd packaging/archlinux/keepassgo-git
|
||||
makepkg -si --noconfirm
|
||||
```
|
||||
|
||||
The installed package version must correspond to the committed source, not a dirty worktree.
|
||||
|
||||
### 3. Build The APK
|
||||
|
||||
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
||||
|
||||
```sh
|
||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||
```
|
||||
|
||||
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
||||
|
||||
### 4. Zip The APK
|
||||
|
||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
||||
- Use a name that includes the commit, for example:
|
||||
`keepassgo-<shortsha>-apk.zip`
|
||||
|
||||
### 5. Upload To Nextcloud
|
||||
|
||||
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
||||
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
||||
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
||||
- Ensure that directory exists with mode `700`.
|
||||
- Create secret temp files with mode `600`.
|
||||
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
||||
|
||||
### 6. Emulator Install And Launch
|
||||
|
||||
- Reuse the existing emulator session if one is already running.
|
||||
- Install with replacement:
|
||||
|
||||
```sh
|
||||
adb install -r build/keepassgo.apk
|
||||
```
|
||||
|
||||
- Launch KeePassGO and confirm it is focused.
|
||||
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
||||
|
||||
### 7. Open A Controlled Demo Vault
|
||||
|
||||
- Do not rely on the user's real vault for this step.
|
||||
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
||||
- Open it in the emulator before closing out `ship it`.
|
||||
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
||||
|
||||
## Closeout Requirements
|
||||
|
||||
When reporting back after `ship it`, include:
|
||||
- the commit that was shipped
|
||||
- the installed Arch package version
|
||||
- the APK path
|
||||
- the uploaded ZIP URL
|
||||
- confirmation that the emulator app was launched
|
||||
- confirmation that the controlled demo vault was opened
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep this workflow specific to KeePassGO.
|
||||
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
||||
- Do not use `rm -rf`.
|
||||
- Do not use `rm -f`.
|
||||
@@ -1,179 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
- "release-*"
|
||||
- "[0-9]+.[0-9]+.[0-9]+*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.1"
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
|
||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||
DIST_DIR: dist
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: keepassgo-android
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install native build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
zsh \
|
||||
pkg-config \
|
||||
libx11-dev \
|
||||
libx11-xcb-dev \
|
||||
libxkbcommon-dev \
|
||||
libxkbcommon-x11-dev \
|
||||
libwayland-dev \
|
||||
libvulkan-dev \
|
||||
libegl1-mesa-dev \
|
||||
libxcursor-dev \
|
||||
libxfixes-dev
|
||||
|
||||
- name: Lint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$state_dir"' EXIT
|
||||
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state_dir="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$state_dir"' EXIT
|
||||
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
|
||||
|
||||
build:
|
||||
needs: lint-test
|
||||
runs-on: keepassgo-android
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install native build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
zsh \
|
||||
pkg-config \
|
||||
libx11-dev \
|
||||
libx11-xcb-dev \
|
||||
libxkbcommon-dev \
|
||||
libxkbcommon-x11-dev \
|
||||
libwayland-dev \
|
||||
libvulkan-dev \
|
||||
libegl1-mesa-dev \
|
||||
libxcursor-dev \
|
||||
libxfixes-dev
|
||||
|
||||
- name: Prepare dist directory
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
- name: Build desktop binaries
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_version="$(git describe --tags --always --dirty)"
|
||||
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
|
||||
# Keep the CI matrix to targets this runner can build reproducibly.
|
||||
for target in \
|
||||
"linux amd64" \
|
||||
"windows amd64" \
|
||||
"windows arm64"
|
||||
do
|
||||
set -- ${target}
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
ext=""
|
||||
cgo_enabled=0
|
||||
if [[ "${goos}" == "linux" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
if [[ "${goos}" == "windows" ]]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
||||
done
|
||||
|
||||
- name: Build APK
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
@@ -1,9 +1,2 @@
|
||||
build/
|
||||
*.apk
|
||||
/keepassgo
|
||||
android/keepassgo-android.jar
|
||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||
packaging/archlinux/keepassgo-git/pkg/
|
||||
packaging/archlinux/keepassgo-git/src/
|
||||
packaging/archlinux/keepassgo-git/keepassgo/
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
|
||||
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
|
||||
## Skills
|
||||
|
||||
- Use the installed Go skills whenever they materially apply to the current slice of work.
|
||||
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
|
||||
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
|
||||
- The available Go skills for this repository are:
|
||||
`go-code-review`,
|
||||
`go-concurrency`,
|
||||
@@ -95,20 +93,6 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||
- The stacked phone layout is the current preferred phone direction.
|
||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||
- Keep the product feeling like the same application on desktop, Android phone,
|
||||
and Android tablet.
|
||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
||||
user's mental model of the workflow.
|
||||
- Use the same action names, the same primary next steps, and the same workflow
|
||||
order across platforms unless there is a hard platform constraint.
|
||||
- Treat workflow prominence and reachability as product behavior, not visual
|
||||
polish. A feature is not parity-complete if it technically exists but is
|
||||
harder to discover or reach on one platform.
|
||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
||||
than platform-specific workflow branches.
|
||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||
and consistent with the product theme.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -118,20 +102,6 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||
- Treat the vault model as local-first across all platforms:
|
||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
||||
that local vault rather than replacing it as the primary object.
|
||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
||||
Android tablet:
|
||||
shared remote configuration belongs in the vault,
|
||||
cross-platform workflow stays the same,
|
||||
and only Android's initial KDBX share/import transport may differ.
|
||||
- Do not persist remote credentials in plaintext app-local state.
|
||||
Keep only non-secret binding metadata outside the vault.
|
||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
||||
design:
|
||||
opening or creating the local vault is the main workflow,
|
||||
and remote setup, remote settings, and remote use attach to that vault.
|
||||
|
||||
## Delivery Discipline
|
||||
|
||||
@@ -142,14 +112,6 @@ These features are product requirements, not “nice to have” ideas.
|
||||
implement the minimum code to satisfy them,
|
||||
verify with `go test ./...` and relevant lint checks,
|
||||
and commit each completed behavior.
|
||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
||||
just feature or label parity.
|
||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
||||
assert the same conceptual next step across desktop, phone, and tablet
|
||||
layouts.
|
||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
||||
emulator or device for the exact flow being changed. Do not count “the same
|
||||
buttons exist somewhere on screen” as sufficient validation.
|
||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||
- If blocked, state the blocker concretely and stop only at that point.
|
||||
|
||||
@@ -158,39 +120,14 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Plan for direct KDBX support.
|
||||
- Plan for direct WebDAV-based workflows.
|
||||
- Avoid adding npm-based or browser-stack dependencies.
|
||||
- Keep remote configuration and synchronization local-first:
|
||||
the app should maintain a live local KDBX cache even when using a remote
|
||||
store, so remote outage does not eliminate vault access.
|
||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
||||
storage.
|
||||
- On Android, use system picker/share mechanisms for local vault import/export
|
||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
||||
|
||||
## Tooling
|
||||
|
||||
- Keep `golangci-lint` passing.
|
||||
- Keep `go test ./...` passing.
|
||||
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
|
||||
- Keep the Android build requirements aligned with the known working setup:
|
||||
`ANDROID_SDK_ROOT=/opt/android-sdk`,
|
||||
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
|
||||
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
|
||||
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
|
||||
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
|
||||
- Remember the known Android runtime regression:
|
||||
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
|
||||
- When validating an APK in the emulator, prefer the known KeePassGO setup:
|
||||
AVD `KeepassGoAPI35`,
|
||||
package `org.julianfamily.keepassgo`,
|
||||
activity `org.gioui.GioActivity`.
|
||||
- Do not run emulator/manual APK tests against the user’s real persisted app state.
|
||||
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible.
|
||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||
- For the Arch package, treat
|
||||
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
|
||||
as the source of truth and regenerate `PKGBUILD` from the template before
|
||||
building.
|
||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
|
||||
- Do not commit generated binaries.
|
||||
|
||||
@@ -12,7 +12,6 @@ Environment:
|
||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
||||
- `APP_ID` overrides the Android application id.
|
||||
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||
- `APK_OUT` overrides the output path.
|
||||
- `APK_VERSION` overrides the packaged app version.
|
||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||
@@ -29,12 +28,12 @@ Installed machine prerequisites expected by this repo:
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ./cmd/keepassgo ...
|
||||
go tool gogio -target android ...
|
||||
```
|
||||
|
||||
The Android build uses the branded icon asset at:
|
||||
|
||||
- `internal/assets/keepassgo-icon.png`
|
||||
- `assets/keepassgo-icon.png`
|
||||
|
||||
Note:
|
||||
|
||||
|
||||
@@ -5,27 +5,10 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
||||
APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
SIGNPASS ?=
|
||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||
ARCH_REPO_DIR ?= $(CURDIR)
|
||||
|
||||
GOGIO_SIGN_FLAGS :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
||||
endif
|
||||
ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
.PHONY: apk
|
||||
apk: android/keepassgo-android.jar
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||
@@ -41,14 +24,12 @@ apk: android/keepassgo-android.jar
|
||||
go tool gogio -target android \
|
||||
-buildmode exe \
|
||||
-appid $(APP_ID) \
|
||||
-ldflags "$(GO_LDFLAGS)" \
|
||||
$(GOGIO_SIGN_FLAGS) \
|
||||
-o $(APK_OUT) \
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_SDK) \
|
||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||
-icon internal/assets/keepassgo-icon.png \
|
||||
./cmd/keepassgo
|
||||
-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; }
|
||||
@@ -61,10 +42,3 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
-d "$$tmpdir" \
|
||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||
|
||||
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||
@sed \
|
||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||
|
||||
@@ -38,34 +38,9 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
||||
Desktop build:
|
||||
|
||||
```bash
|
||||
go build ./cmd/keepassgo
|
||||
go build ./...
|
||||
```
|
||||
|
||||
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
||||
You can override the version shown in KeePassGO with:
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
||||
```
|
||||
|
||||
## Arch Linux Package
|
||||
|
||||
An AUR-style package definition for the Linux desktop client lives under:
|
||||
|
||||
- `packaging/archlinux/keepassgo-git/`
|
||||
|
||||
From that directory you can build and install it with:
|
||||
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
The package installs:
|
||||
|
||||
- `/usr/bin/keepassgo`
|
||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||
- application icons under the hicolor theme
|
||||
|
||||
## Android Packaging
|
||||
|
||||
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
||||
@@ -89,7 +64,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
||||
Package:
|
||||
|
||||
```bash
|
||||
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
||||
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.
|
||||
|
||||
@@ -11,36 +11,6 @@ The product is not complete until the global exit criteria at the end of this fi
|
||||
These items came from a hands-on emulator and desktop walkthrough.
|
||||
They should be treated as usability work, not just polish.
|
||||
|
||||
### Cross-Platform Workflow Parity
|
||||
|
||||
These items are required to keep desktop, Android phone, and Android tablet
|
||||
feeling like the same application rather than three related UIs.
|
||||
|
||||
- Workflow parity:
|
||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
||||
sync, browse entries, and edit entries.
|
||||
- Workflow parity:
|
||||
ensure desktop, phone, and tablet use the same action names and the same
|
||||
primary next steps for those workflows.
|
||||
- Workflow parity:
|
||||
remove or reduce platform-specific workflow exceptions where the same user
|
||||
intent currently takes a different route on different form factors.
|
||||
- Testing:
|
||||
add cross-mode behavior tests that assert workflow order and action
|
||||
prominence, not just label presence.
|
||||
- Testing:
|
||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
||||
action on desktop, phone, and tablet layouts.
|
||||
- Testing:
|
||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
||||
removal are reachable from the same primary affordance family across modes.
|
||||
- Android verification:
|
||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
||||
including with the on-screen keyboard visible.
|
||||
- Android verification:
|
||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
||||
failure, not as acceptable platform variation.
|
||||
|
||||
### Primary Workflow Changes
|
||||
|
||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||
|
||||
@@ -22,26 +22,3 @@
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/keepassgo_accessibility_service" />
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||
android:authorities="org.julianfamily.keepassgo.share"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<activity
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/x-keepass2" />
|
||||
<data android:mimeType="application/vnd.keepass" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public final class AndroidShare {
|
||||
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
||||
|
||||
private AndroidShare() {
|
||||
}
|
||||
|
||||
public static void shareVault(Context context, String path, String title) throws IOException {
|
||||
File source = new File(path);
|
||||
if (!source.isFile()) {
|
||||
throw new IOException("vault file not found: " + path);
|
||||
}
|
||||
File shared = copyToSharedExport(context, source);
|
||||
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
||||
|
||||
Intent send = new Intent(Intent.ACTION_SEND);
|
||||
send.setType("application/x-keepass2");
|
||||
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
Intent chooser = Intent.createChooser(send, "Share vault");
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
context.startActivity(chooser);
|
||||
}
|
||||
|
||||
static File sharedDirectory(Context context) {
|
||||
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
||||
}
|
||||
|
||||
private static File copyToSharedExport(Context context, File source) throws IOException {
|
||||
File dir = sharedDirectory(context);
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File target = new File(dir, sanitizeFilename(source.getName()));
|
||||
try (FileInputStream in = new FileInputStream(source);
|
||||
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
String trimmed = name == null ? "" : name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
if (trimmed.endsWith(".kdbx")) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed + ".kdbx";
|
||||
}
|
||||
|
||||
private static String sanitizeTitle(String title, String fallbackName) {
|
||||
String trimmed = title == null ? "" : title.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed;
|
||||
}
|
||||
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
||||
if (!fallback.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class SharedVaultImportActivity extends Activity {
|
||||
private static final String TAG = "KeePassGOImport";
|
||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
handleIntent(getIntent());
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleIntent(intent);
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void handleIntent(Intent intent) {
|
||||
Uri uri = resolveSharedUri(intent);
|
||||
if (uri == null) {
|
||||
Log.i(TAG, "no shared vault URI on intent");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
persistPendingImport(uri);
|
||||
Log.i(TAG, "queued shared vault import from " + uri);
|
||||
} catch (IOException | RuntimeException err) {
|
||||
Log.e(TAG, "failed to queue shared vault import", err);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri resolveSharedUri(Intent intent) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_SEND.equals(action)) {
|
||||
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
return intent.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistPendingImport(Uri uri) throws IOException {
|
||||
File dir = new File(getFilesDir(), "keepassgo");
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
throw new IOException("failed to open shared vault stream");
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveDisplayName(Uri uri) {
|
||||
String displayName = queryDisplayName(uri);
|
||||
if (!displayName.isEmpty()) {
|
||||
return displayName;
|
||||
}
|
||||
String lastSegment = uri.getLastPathSegment();
|
||||
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||
return lastSegment.trim();
|
||||
}
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
private String queryDisplayName(Uri uri) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index >= 0) {
|
||||
String value = cursor.getString(index);
|
||||
if (value != null) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException err) {
|
||||
Log.w(TAG, "failed to query display name", err);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void launchMainActivity() {
|
||||
Intent launch = new Intent();
|
||||
launch.setClassName(this, "org.gioui.GioActivity");
|
||||
startActivity(launch);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
public final class SharedVaultProvider extends ContentProvider {
|
||||
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||
File file = resolveSharedFile(uri);
|
||||
String[] columns = projection;
|
||||
if (columns == null || columns.length == 0) {
|
||||
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
Object[] row = new Object[columns.length];
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
switch (columns[i]) {
|
||||
case OpenableColumns.DISPLAY_NAME:
|
||||
row[i] = file.getName();
|
||||
break;
|
||||
case OpenableColumns.SIZE:
|
||||
row[i] = file.length();
|
||||
break;
|
||||
default:
|
||||
row[i] = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor.addRow(row);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return "application/x-keepass2";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("insert is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("delete is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("update is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
File file = resolveSharedFile(uri);
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
static Uri uriForFile(String name) {
|
||||
return new Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(name)
|
||||
.build();
|
||||
}
|
||||
|
||||
private File resolveSharedFile(Uri uri) {
|
||||
if (getContext() == null) {
|
||||
throw new IllegalStateException("provider context is unavailable");
|
||||
}
|
||||
String name = sanitizeFilename(uri.getLastPathSegment());
|
||||
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
if (name == null) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
String trimmed = name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
return new File(trimmed).getName();
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,26 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"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
|
||||
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) {
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"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"
|
||||
)
|
||||
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
if err := lifecycle.Create(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t),
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"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"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
token.Entry([]string{"Root", "API Tokens"}),
|
||||
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
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"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
|
||||
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
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)
|
||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||
BaseUrl: "https://dav.example.com",
|
||||
Path: "vaults/main.kdbx",
|
||||
Username: "rustyryan",
|
||||
Username: "jjulian",
|
||||
Password: "dav-token",
|
||||
MasterPassword: "correct horse battery staple",
|
||||
}); err != nil {
|
||||
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||
}
|
||||
|
||||
if resp.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
|
||||
if resp.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
||||
}
|
||||
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
|
||||
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
|
||||
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
||||
Id: "vault-console",
|
||||
Id: "git-server",
|
||||
Target: "password",
|
||||
}); err != nil {
|
||||
t.Fatalf("CopyEntryField() error = %v", err)
|
||||
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
||||
Entry: &keepassgov1.Entry{
|
||||
Id: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Id: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Url: "https://surveillance.crew.example.invalid",
|
||||
Url: "https://lights.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "lights-admin",
|
||||
},
|
||||
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if upserted.Entry.Title != "Surveillance Console" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
|
||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
||||
}
|
||||
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
|
||||
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
|
||||
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
|
||||
t.Fatalf("DeleteEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if restored.Entry.Title != "Vault Console" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
|
||||
if restored.Entry.Title != "Git Server" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
|
||||
}
|
||||
|
||||
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||
TemplateId: "website-login",
|
||||
Overrides: &keepassgov1.Entry{
|
||||
Id: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Id: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
Url: "https://bellagio.example.invalid",
|
||||
Url: "https://www.dynadot.com",
|
||||
Fields: map[string]string{
|
||||
"Environment": "staging",
|
||||
},
|
||||
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
|
||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
||||
}
|
||||
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
||||
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
||||
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntryHistory() error = %v", err)
|
||||
}
|
||||
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
||||
Id: "vault-console",
|
||||
Id: "git-server",
|
||||
HistoryIndex: 0,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
uploaded := []byte("attachment-content")
|
||||
|
||||
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
Content: uploaded,
|
||||
}); err != nil {
|
||||
t.Fatalf("UploadAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -1053,64 +1053,64 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry
|
||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
t.Helper()
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "git-server-h1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-0",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.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{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
{
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
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: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return newTestClientForModel(t, model)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,20 +22,20 @@ var (
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
)
|
||||
|
||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
)
|
||||
|
||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
@@ -26,7 +26,6 @@ const (
|
||||
SectionRecycleBin Section = "recycle-bin"
|
||||
SectionAPITokens Section = "api-tokens"
|
||||
SectionAPIAudit Section = "api-audit"
|
||||
SectionAbout Section = "about"
|
||||
)
|
||||
|
||||
type CurrentSession interface {
|
||||
@@ -62,7 +61,6 @@ type SynchronizableSession interface {
|
||||
type AdvancedSynchronizableSession interface {
|
||||
CurrentSession
|
||||
SynchronizeFromLocal(string) error
|
||||
SynchronizeFromLocalBytes(string, []byte) error
|
||||
SynchronizeToLocal(string) error
|
||||
SynchronizeFromRemote(webdav.Client, string) error
|
||||
SynchronizeToRemote(webdav.Client, string) error
|
||||
@@ -133,52 +131,6 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||
return apitokens.Entries(model)
|
||||
}
|
||||
|
||||
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := slices.Clone(model.RemoteProfiles)
|
||||
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := slices.Clone(model.Entries)
|
||||
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -350,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||
return model.EntriesUnderPath(s.CurrentPath), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
@@ -423,7 +375,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||
return slices.Clone(model.Templates)
|
||||
case SectionRecycleBin:
|
||||
return slices.Clone(model.RecycleBin)
|
||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||
case SectionAPITokens, SectionAPIAudit:
|
||||
return nil
|
||||
default:
|
||||
return slices.Clone(model.Entries)
|
||||
@@ -770,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
|
||||
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 {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
@@ -880,66 +820,6 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||
}
|
||||
|
||||
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, input)
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
)
|
||||
|
||||
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -39,26 +39,25 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
titles = append(titles, entry.Title)
|
||||
}
|
||||
|
||||
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
|
||||
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
|
||||
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
|
||||
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
||||
func TestVisibleEntriesIncludesDescendantEntriesAtParentGroup(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", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew"},
|
||||
CurrentPath: []string{"Joe"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -70,8 +69,8 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
||||
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)
|
||||
if !slices.Equal(titles, []string{"Dynadot", "Git Server", "Home Assistant (Codex)"}) {
|
||||
t.Fatalf("visible titles = %v, want descendant entries from Joe", titles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,71 +163,6 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
{
|
||||
ID: "archive-webdav",
|
||||
Name: "Archive Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/archive.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteProfiles() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
|
||||
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteCredentialEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteCredentialEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
|
||||
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -236,14 +170,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
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", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
SearchQuery: "surveillance",
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
SearchQuery: "lights",
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -251,8 +185,8 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
||||
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,13 +197,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
||||
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", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew"},
|
||||
CurrentPath: []string{"Joe"},
|
||||
SearchQuery: "missing",
|
||||
}
|
||||
|
||||
@@ -286,8 +220,8 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
||||
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))
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 3 descendant entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,8 +348,8 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
||||
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -483,13 +417,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew"},
|
||||
CurrentPath: []string{"Joe"},
|
||||
}
|
||||
|
||||
got, err := state.ChildGroups()
|
||||
@@ -497,8 +431,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,19 +469,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.SelectVisibleIndex(1); err != nil {
|
||||
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "vault-console" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
if err := state.ToggleVisibleIndex(1); err != nil {
|
||||
@@ -589,14 +523,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
||||
sess := &mutableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.DeleteSelectedEntry(); err != nil {
|
||||
@@ -611,8 +545,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
||||
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
|
||||
}
|
||||
|
||||
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
|
||||
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
|
||||
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
|
||||
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -626,7 +560,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
sess := &mutableStubSession{
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -635,7 +569,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.RestoreEntry("vault-console"); err != nil {
|
||||
if err := state.RestoreEntry("git-server"); err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -644,8 +578,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
|
||||
if len(got) != 1 || got[0].ID != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -665,27 +599,27 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
}
|
||||
|
||||
entry := vault.Entry{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
if err := state.UpsertEntry(entry); err != nil {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if got := state.SelectedEntryID; got != "vault-console" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -717,11 +651,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
||||
}
|
||||
|
||||
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
|
||||
ID: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
ID: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -732,16 +666,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
||||
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
|
||||
}
|
||||
|
||||
if got := state.SelectedEntryID; got != "bellagio" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
|
||||
if got := state.SelectedEntryID; got != "dynadot" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "bellagio" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
|
||||
if len(got) != 1 || got[0].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -808,24 +742,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
|
||||
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
|
||||
if err != nil {
|
||||
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if duplicate.ID != "vault-console-copy" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
|
||||
if duplicate.ID != "git-server-copy" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
|
||||
}
|
||||
if state.SelectedEntryID != "vault-console-copy" {
|
||||
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
|
||||
if state.SelectedEntryID != "git-server-copy" {
|
||||
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
|
||||
}
|
||||
if len(sess.model.Entries) != 2 {
|
||||
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
||||
@@ -837,13 +771,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
|
||||
@@ -856,8 +790,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
||||
}
|
||||
|
||||
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
|
||||
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
|
||||
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
|
||||
if len(newPath) != 1 || newPath[0].ID != "git-server" {
|
||||
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -871,19 +805,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Password: "new-token",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []vault.Entry{
|
||||
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.RestoreSelectedEntryVersion(0); err != nil {
|
||||
@@ -927,7 +861,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -956,7 +890,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -1007,7 +941,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -1029,199 +963,20 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
|
||||
}
|
||||
|
||||
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if len(state.CurrentPath) != 0 {
|
||||
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||
}
|
||||
if state.SelectedEntryID != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after bound remote open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "missing-profile",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||
}
|
||||
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
|
||||
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||
}
|
||||
credentials, err := sess.model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{Session: stubSession{model: vault.Model{}}}
|
||||
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lockableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.Lock(); err != nil {
|
||||
@@ -1244,7 +999,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
||||
sess := &lockableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
locked: true,
|
||||
@@ -1262,8 +1017,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
|
||||
if len(got) != 1 || got[0].ID != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,7 +1047,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
|
||||
state := State{
|
||||
Section: SectionEntries,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
SearchQuery: "git",
|
||||
}
|
||||
|
||||
@@ -1328,7 +1083,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
ErrorMessage: "previous error",
|
||||
}
|
||||
|
||||
@@ -1372,7 +1127,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||
|
||||
state := State{
|
||||
CurrentPath: []string{"Root"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
state.EnterGroup("Internet")
|
||||
@@ -1390,13 +1145,13 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
|
||||
state := State{
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||
@@ -1429,8 +1184,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,8 +1207,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||
@@ -1526,8 +1281,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||
@@ -1541,14 +1296,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
@@ -1557,8 +1312,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
|
||||
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
|
||||
@@ -1570,7 +1325,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}
|
||||
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
|
||||
@@ -1581,15 +1336,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
|
||||
if err := state.MoveCurrentGroup([]string{"Root", "Joe"}); 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 !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Joe 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 got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" {
|
||||
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
|
||||
@@ -1603,7 +1358,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
|
||||
@@ -1631,7 +1386,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
|
||||
@@ -1657,7 +1412,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
|
||||
@@ -1683,7 +1438,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
|
||||
@@ -1701,7 +1456,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
|
||||
@@ -1727,7 +1482,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
|
||||
@@ -1755,8 +1510,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
|
||||
func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1797,17 +1552,15 @@ func (s *saveableStubSession) Save() error {
|
||||
}
|
||||
|
||||
type lifecycleStubSession struct {
|
||||
createCalls int
|
||||
model vault.Model
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remoteClient webdav.Client
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
createCalls int
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||
return s.model, nil
|
||||
return vault.Model{}, nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||
@@ -1825,32 +1578,11 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||
s.remoteClient = client
|
||||
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
||||
s.remotePath = path
|
||||
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 {
|
||||
s.changedKey = key
|
||||
return nil
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
Username: "joe",
|
||||
Password: "secret",
|
||||
URL: "https://10.0.2.2:8443/login",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
Path: []string{"Joe", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
Title: "Bare Host",
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
URL: "surveillance.crew.example.invalid",
|
||||
URL: "lights.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"AndroidApp1": "androidapp://com.lights.mobile",
|
||||
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
|
||||
"KP2A_URL_1": "https://lights.julianfamily.org/account",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
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 got.Entries[1].Host != "lights.julianfamily.org" {
|
||||
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
|
||||
}
|
||||
if len(got.Entries[1].Targets) != 3 {
|
||||
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
|
||||
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
|
||||
Title: "Host A",
|
||||
Username: "first",
|
||||
Password: "secret1",
|
||||
URL: "https://surveillance.crew.example.invalid/",
|
||||
Host: "surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org/",
|
||||
Host: "lights.julianfamily.org",
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
Title: "Host B",
|
||||
Username: "second",
|
||||
Password: "secret2",
|
||||
URL: "https://surveillance.crew.example.invalid/",
|
||||
Host: "surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org/",
|
||||
Host: "lights.julianfamily.org",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
|
||||
if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok {
|
||||
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,9 @@ const (
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -26,7 +25,6 @@ type Config struct {
|
||||
AppID string
|
||||
APKOut string
|
||||
Version string
|
||||
Ldflags string
|
||||
MinSDK string
|
||||
TargetSDK string
|
||||
IconPath string
|
||||
@@ -40,7 +38,6 @@ func DefaultConfig() Config {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: DefaultIconPath,
|
||||
@@ -52,13 +49,12 @@ func (c Config) GogioArgs() []string {
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", c.AppID,
|
||||
"-ldflags", c.Ldflags,
|
||||
"-o", c.APKOut,
|
||||
"-version", c.Version,
|
||||
"-minsdk", c.MinSDK,
|
||||
"-targetsdk", c.TargetSDK,
|
||||
"-icon", c.IconPath,
|
||||
"./cmd/keepassgo",
|
||||
".",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,12 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||
"-target", "android",
|
||||
"-buildmode", "exe",
|
||||
"-appid", DefaultAppID,
|
||||
"-ldflags", DefaultLdflags,
|
||||
"-o", DefaultAPKOut,
|
||||
"-version", DefaultVersion,
|
||||
"-minsdk", DefaultMinSDK,
|
||||
"-targetsdk", DefaultTargetSDK,
|
||||
"-icon", DefaultIconPath,
|
||||
"./cmd/keepassgo",
|
||||
".",
|
||||
}
|
||||
|
||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||
@@ -53,7 +52,6 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
IconPath: filepath.Join(root, "icon.png"),
|
||||
@@ -79,7 +77,6 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
||||
AppID: DefaultAppID,
|
||||
APKOut: DefaultAPKOut,
|
||||
Version: DefaultVersion,
|
||||
Ldflags: DefaultLdflags,
|
||||
MinSDK: DefaultMinSDK,
|
||||
TargetSDK: DefaultTargetSDK,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
systemclipboard "github.com/atotto/clipboard"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
)
|
||||
|
||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
target Target
|
||||
want string
|
||||
}{
|
||||
{name: "username", target: TargetUsername, want: "dannyocean"},
|
||||
{name: "username", target: TargetUsername, want: "joejulian"},
|
||||
{name: "password", target: TargetPassword, want: "token-1"},
|
||||
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
|
||||
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
var writer memoryWriter
|
||||
service := Service{Writer: &writer}
|
||||
|
||||
if err := service.Copy(model, "vault-console", tt.target); err != nil {
|
||||
if err := service.Copy(model, "git-server", tt.target); err != nil {
|
||||
t.Fatalf("Copy() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
|
||||
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
|
||||
}
|
||||
err = service.Copy(model, "vault-console", Target("unsupported"))
|
||||
err = service.Copy(model, "git-server", Target("unsupported"))
|
||||
if !errors.Is(err, ErrUnsupportedTarget) {
|
||||
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
|
||||
}
|
||||
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
|
||||
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Password: "token-1"},
|
||||
{ID: "git-server", Password: "token-1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := service.Copy(model, "vault-console", TargetPassword)
|
||||
err := service.Copy(model, "git-server", TargetPassword)
|
||||
if !errors.Is(err, ErrWriteFailed) {
|
||||
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
||||
|
||||
func main() {
|
||||
appui.Main()
|
||||
}
|
||||
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
||||
- 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).
|
||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go).
|
||||
|
||||
## Current screen-reader boundary
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Local-First Remote Sync Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||
|
||||
- every vault is a local KDBX file first
|
||||
- remote sync is an optional binding on top of that local file
|
||||
- shared remote configuration lives in the vault
|
||||
- user-specific remote credentials live in the vault
|
||||
- app-local state stores only non-secret binding metadata
|
||||
|
||||
Android adds only one platform-specific capability on top of that model:
|
||||
|
||||
- share/import the initial local KDBX file between devices
|
||||
|
||||
## Product Rules
|
||||
|
||||
1. A remote-backed vault must always have a local cache KDBX file.
|
||||
2. Opening a remote-backed vault should open the local KDBX first.
|
||||
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||
4. Remote credentials must not be stored in plaintext app-local state.
|
||||
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||
7. Sync must support both manual and automatic modes.
|
||||
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||
|
||||
## Target Data Model
|
||||
|
||||
### In-Vault Shared Remote Profile
|
||||
|
||||
Store a reusable remote profile in the vault with fields such as:
|
||||
|
||||
- profile ID
|
||||
- profile name
|
||||
- backend type, initially WebDAV
|
||||
- base URL
|
||||
- remote object path
|
||||
- optional notes or labels
|
||||
- default sync policy, if shared defaults are desirable
|
||||
|
||||
### In-Vault User Credential Binding
|
||||
|
||||
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||
|
||||
- remote profile ID
|
||||
- credential entry UUID, or another stable internal reference
|
||||
- optional username field override if needed
|
||||
|
||||
The credential entry should contain the actual username/password or token.
|
||||
|
||||
### Local App State
|
||||
|
||||
Persist only non-secret binding state such as:
|
||||
|
||||
- local vault path
|
||||
- selected remote profile ID
|
||||
- selected credential entry reference
|
||||
- sync policy override
|
||||
- last sync metadata
|
||||
- conflict or recovery markers
|
||||
|
||||
## Core Flows
|
||||
|
||||
### Create Or Configure Remote Sync
|
||||
|
||||
1. Open or create a local vault.
|
||||
2. Create or edit a shared remote profile in that vault.
|
||||
3. Create or select a credential entry in that vault.
|
||||
4. Bind the local vault to the selected remote profile and credential reference.
|
||||
5. Choose manual or automatic sync behavior.
|
||||
|
||||
### Reopen Existing Remote-Backed Vault
|
||||
|
||||
1. Open the local vault file from app state.
|
||||
2. Resolve the selected remote profile from vault contents.
|
||||
3. Resolve the credential entry from vault contents.
|
||||
4. Offer or perform sync based on the binding policy.
|
||||
|
||||
### Bootstrap A New Android Device
|
||||
|
||||
1. Share the local KDBX file through Android Sharesheet.
|
||||
2. Import and open that KDBX locally on the new device.
|
||||
3. Select a remote profile stored in the vault.
|
||||
4. Select or create that user’s credential entry in the vault.
|
||||
5. Bind the local vault as the cache for the remote-backed setup.
|
||||
|
||||
## Migration Requirements
|
||||
|
||||
1. Migrate existing remote connections that save credentials in app state.
|
||||
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||
3. Replace saved plaintext credential state with a vault credential reference.
|
||||
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Domain Model
|
||||
|
||||
- define remote profile structures independent of Gio UI
|
||||
- define credential reference structures independent of Gio UI
|
||||
- define sync binding state independent of Gio UI
|
||||
- add behavior tests for local-first remote-backed vaults
|
||||
|
||||
### Phase 2: Vault Storage
|
||||
|
||||
- persist remote profiles in the vault
|
||||
- persist credential references in the vault
|
||||
- resolve credentials from normal vault entries
|
||||
- add behavior tests for read/write and lookup semantics
|
||||
|
||||
### Phase 3: State And Open Flow
|
||||
|
||||
- shrink app state to non-secret metadata only
|
||||
- update open flows to always prefer the local cache vault
|
||||
- update reopen behavior on all platforms to use the same model
|
||||
- add migration coverage for old remote state
|
||||
|
||||
### Phase 4: Sync Binding
|
||||
|
||||
- bind a local vault to a selected remote profile
|
||||
- support manual sync
|
||||
- support automatic sync on open/save
|
||||
- define conflict and remote-failure handling for the local cache model
|
||||
|
||||
### Phase 5: Android Bootstrap
|
||||
|
||||
- add Android Sharesheet export of the current local KDBX
|
||||
- add Android import flow for a shared KDBX
|
||||
- keep the remote pivot flow consistent with desktop after local open
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||
4. How should multiple remote profiles per vault be presented in the UI?
|
||||
5. What should happen when the credential entry reference no longer resolves?
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
Implement the shared domain model and tests first:
|
||||
|
||||
- model a local vault plus optional remote binding
|
||||
- define in-vault remote profile and credential reference semantics
|
||||
- add tests proving app state no longer needs plaintext remote credentials
|
||||
|
||||
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||
@@ -2,9 +2,10 @@ module git.julianfamily.org/keepassgo
|
||||
|
||||
go 1.26
|
||||
|
||||
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
gioui.org/x v0.8.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||
@@ -17,7 +18,6 @@ require (
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
gioui.org/cmd v0.8.0 // indirect
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||
github.com/Antonboom/errname v1.0.0 // indirect
|
||||
@@ -74,7 +74,6 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.0.6 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
@@ -193,7 +192,7 @@ require (
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
|
||||
@@ -39,15 +39,9 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
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/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||
@@ -230,8 +224,6 @@ 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/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -677,8 +669,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
|
||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type SyncMode string
|
||||
|
||||
const (
|
||||
SyncModeManual SyncMode = "manual"
|
||||
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||
)
|
||||
|
||||
type RemoteBinding struct {
|
||||
LocalVaultPath string `json:"localVaultPath"`
|
||||
RemoteProfileID string `json:"remoteProfileId"`
|
||||
CredentialEntryID string `json:"credentialEntryId"`
|
||||
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||
}
|
||||
|
||||
type ResolvedRemoteBinding struct {
|
||||
Profile vault.RemoteProfile
|
||||
Credentials vault.Entry
|
||||
}
|
||||
|
||||
type RemoteBindingInput struct {
|
||||
LocalVaultPath string
|
||||
RemoteProfileID string
|
||||
RemoteProfileName string
|
||||
BaseURL string
|
||||
RemotePath string
|
||||
CredentialEntryID string
|
||||
CredentialTitle string
|
||||
Username string
|
||||
Password string
|
||||
CredentialPath []string
|
||||
SyncMode SyncMode
|
||||
}
|
||||
|
||||
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||
}
|
||||
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||
}
|
||||
return ResolvedRemoteBinding{
|
||||
Profile: profile,
|
||||
Credentials: credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
||||
if model == nil {
|
||||
return RemoteBinding{}, fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
||||
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
||||
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
||||
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
||||
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
||||
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
||||
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
|
||||
switch {
|
||||
case input.LocalVaultPath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
||||
case input.RemoteProfileID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
||||
case input.BaseURL == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
||||
case input.RemotePath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
||||
case input.CredentialEntryID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
||||
case input.Password == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
||||
}
|
||||
|
||||
if input.RemoteProfileName == "" {
|
||||
input.RemoteProfileName = input.RemoteProfileID
|
||||
}
|
||||
if input.CredentialTitle == "" {
|
||||
input.CredentialTitle = "Remote Sign-In"
|
||||
}
|
||||
|
||||
model.UpsertRemoteProfile(vault.RemoteProfile{
|
||||
ID: input.RemoteProfileID,
|
||||
Name: input.RemoteProfileName,
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: input.BaseURL,
|
||||
Path: input.RemotePath,
|
||||
})
|
||||
model.UpsertEntry(vault.Entry{
|
||||
ID: input.CredentialEntryID,
|
||||
Title: input.CredentialTitle,
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
URL: input.BaseURL,
|
||||
Path: append([]string(nil), input.CredentialPath...),
|
||||
})
|
||||
|
||||
return RemoteBinding{
|
||||
LocalVaultPath: input.LocalVaultPath,
|
||||
RemoteProfileID: input.RemoteProfileID,
|
||||
CredentialEntryID: input.CredentialEntryID,
|
||||
SyncMode: normalizeSyncMode(input.SyncMode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
||||
return fmt.Errorf("remote profile id is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
||||
return fmt.Errorf("credential entry id is required")
|
||||
}
|
||||
|
||||
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
||||
model.RemoveEntryByID(binding.CredentialEntryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSyncMode(mode SyncMode) SyncMode {
|
||||
switch mode {
|
||||
case SyncModeAutomaticOnOpenSave:
|
||||
return SyncModeAutomaticOnOpenSave
|
||||
default:
|
||||
return SyncModeManual
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "linuscaldwell-webdav",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
binding := RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "linuscaldwell-webdav",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||
}
|
||||
|
||||
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}}
|
||||
|
||||
_, err = (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := json.Marshal(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||
}
|
||||
|
||||
text := string(content)
|
||||
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
||||
if strings.Contains(text, disallowed) {
|
||||
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if len(model.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
||||
}
|
||||
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
|
||||
credentials, err := model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||
}
|
||||
|
||||
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
||||
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
||||
}
|
||||
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input RemoteBindingInput
|
||||
}{
|
||||
{
|
||||
name: "missing_local_vault_path",
|
||||
input: RemoteBindingInput{
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_remote_base_url",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_credential_entry_id",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
||||
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}
|
||||
|
||||
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if got := len(model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type AuditQuickFilter struct {
|
||||
Label string
|
||||
Query string
|
||||
}
|
||||
|
||||
func Operations() []apitokens.Operation {
|
||||
return []apitokens.Operation{
|
||||
apitokens.OperationListEntries,
|
||||
apitokens.OperationListGroups,
|
||||
apitokens.OperationReadEntry,
|
||||
apitokens.OperationCopyPassword,
|
||||
apitokens.OperationCopyUsername,
|
||||
apitokens.OperationCopyURL,
|
||||
apitokens.OperationMutateEntry,
|
||||
apitokens.OperationMutateGroup,
|
||||
apitokens.OperationManageVault,
|
||||
}
|
||||
}
|
||||
|
||||
func AuditDecisionLabel(eventType apiaudit.EventType) string {
|
||||
switch eventType {
|
||||
case apiaudit.EventApprovalRequested:
|
||||
return "Requested"
|
||||
case apiaudit.EventApprovalAllowed:
|
||||
return "Allowed"
|
||||
case apiaudit.EventApprovalDenied:
|
||||
return "Denied"
|
||||
case apiaudit.EventApprovalCanceled:
|
||||
return "Canceled"
|
||||
case apiaudit.EventApprovalTimedOut:
|
||||
return "Timed Out"
|
||||
case apiaudit.EventAuthRejected:
|
||||
return "Auth Rejected"
|
||||
default:
|
||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func AuditOperationLabel(operation apitokens.Operation) string {
|
||||
if strings.TrimSpace(string(operation)) == "" {
|
||||
return "Other"
|
||||
}
|
||||
return strings.ReplaceAll(string(operation), "_", " ")
|
||||
}
|
||||
|
||||
func CompactAuditFilterLabel(label string) string {
|
||||
label = strings.TrimSpace(label)
|
||||
if len(label) <= 22 {
|
||||
return label
|
||||
}
|
||||
return label[:19] + "..."
|
||||
}
|
||||
|
||||
func AuditEventSearchTerms(event apiaudit.Event) string {
|
||||
parts := []string{
|
||||
string(event.Type),
|
||||
AuditDecisionLabel(event.Type),
|
||||
event.TokenName,
|
||||
event.ClientName,
|
||||
string(event.Operation),
|
||||
AuditOperationLabel(event.Operation),
|
||||
strings.Join(event.Resource.Path, " / "),
|
||||
event.Resource.EntryID,
|
||||
event.Message,
|
||||
}
|
||||
switch event.Type {
|
||||
case apiaudit.EventApprovalAllowed:
|
||||
parts = append(parts, "allow approved")
|
||||
case apiaudit.EventApprovalDenied:
|
||||
parts = append(parts, "deny denied")
|
||||
case apiaudit.EventApprovalRequested:
|
||||
parts = append(parts, "prompt requested")
|
||||
case apiaudit.EventApprovalCanceled:
|
||||
parts = append(parts, "cancel canceled")
|
||||
case apiaudit.EventApprovalTimedOut:
|
||||
parts = append(parts, "timeout timed out")
|
||||
case apiaudit.EventAuthRejected:
|
||||
parts = append(parts, "rejected unauthorized")
|
||||
}
|
||||
return strings.ToLower(strings.Join(parts, " "))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package layout
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeLocked Mode = "locked"
|
||||
ModeStatic Mode = "static"
|
||||
ModeEmpty Mode = "empty"
|
||||
ModeEditor Mode = "editor"
|
||||
ModeView Mode = "view"
|
||||
)
|
||||
|
||||
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
||||
switch {
|
||||
case isLocked:
|
||||
return ModeLocked
|
||||
case hasStaticPanel:
|
||||
return ModeStatic
|
||||
case !hasSelectedEntry && !editing:
|
||||
return ModeEmpty
|
||||
case editing:
|
||||
return ModeEditor
|
||||
default:
|
||||
return ModeView
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package detail
|
||||
|
||||
type EmptyState struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
type VaultSummary struct {
|
||||
Title string
|
||||
Detail string
|
||||
Context string
|
||||
}
|
||||
|
||||
type AttachmentItem struct {
|
||||
Name string
|
||||
Size int
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package editor
|
||||
|
||||
import "strings"
|
||||
|
||||
type Field string
|
||||
|
||||
const (
|
||||
FieldID Field = "id"
|
||||
FieldTitle Field = "title"
|
||||
FieldUsername Field = "username"
|
||||
FieldPassword Field = "password"
|
||||
FieldURL Field = "url"
|
||||
FieldPath Field = "path"
|
||||
FieldTags Field = "tags"
|
||||
FieldPasswordProfile Field = "password-profile"
|
||||
FieldNotes Field = "notes"
|
||||
FieldFields Field = "fields"
|
||||
FieldHistoryIndex Field = "history-index"
|
||||
)
|
||||
|
||||
func Label(field Field) string {
|
||||
switch field {
|
||||
case FieldID:
|
||||
return "ID"
|
||||
case FieldTitle:
|
||||
return "Title"
|
||||
case FieldUsername:
|
||||
return "Username"
|
||||
case FieldPassword:
|
||||
return "Password"
|
||||
case FieldURL:
|
||||
return "URL"
|
||||
case FieldPath:
|
||||
return "Path"
|
||||
case FieldTags:
|
||||
return "Tags"
|
||||
case FieldPasswordProfile:
|
||||
return "Password Profile"
|
||||
case FieldNotes:
|
||||
return "Notes"
|
||||
case FieldFields:
|
||||
return "Custom Fields"
|
||||
case FieldHistoryIndex:
|
||||
return "History Index"
|
||||
default:
|
||||
return strings.ReplaceAll(string(field), "-", " ")
|
||||
}
|
||||
}
|
||||
|
||||
func FocusOrder() []Field {
|
||||
return []Field{
|
||||
FieldID,
|
||||
FieldTitle,
|
||||
FieldUsername,
|
||||
FieldPassword,
|
||||
FieldURL,
|
||||
FieldPath,
|
||||
FieldTags,
|
||||
FieldPasswordProfile,
|
||||
FieldNotes,
|
||||
FieldFields,
|
||||
FieldHistoryIndex,
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
)
|
||||
|
||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
if u.usesCompactViewport() {
|
||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return u.headerActions(gtx)
|
||||
}
|
||||
if u.shouldShowDesktopWorkingHeader() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.brandMark(gtx, 196, 56)
|
||||
}),
|
||||
layout.Flexed(1, u.headerActions),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
rowDims := cluster.layout(gtx, u)
|
||||
if u.usesCompactViewport() {
|
||||
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
||||
}
|
||||
|
||||
if u.usesCompactViewport() {
|
||||
cluster.prepareCompactMenus(gtx, u)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
||||
}
|
||||
return rowDims
|
||||
}
|
||||
|
||||
type headerActionCluster struct {
|
||||
Metrics headerlayout.ActionMetrics
|
||||
SyncMenu layout.Widget
|
||||
MainMenu layout.Widget
|
||||
}
|
||||
|
||||
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
||||
|
||||
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
||||
|
||||
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
||||
cluster := headerActionCluster{
|
||||
SyncMenu: u.syncMenuWidget(),
|
||||
MainMenu: u.mainMenuWidget(),
|
||||
}
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
||||
if !u.usesCompactViewport() {
|
||||
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
||||
}
|
||||
return cluster
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
|
||||
rowOps := op.Record(gtx.Ops)
|
||||
c.Metrics.RowDims = c.layoutRow(gtx, u)
|
||||
rowCall := rowOps.Stop()
|
||||
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
rowCall.Add(gtx.Ops)
|
||||
return c.Metrics.RowDims
|
||||
})
|
||||
}
|
||||
|
||||
func (c headerActionCluster) activeMenu() layout.Widget {
|
||||
switch {
|
||||
case c.ShowSyncMenu():
|
||||
return c.SyncMenu
|
||||
case c.ShowMainMenu():
|
||||
return c.MainMenu
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
||||
return c.Metrics.SyncDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||
c.Metrics.LockDims = btn.Layout(gtx)
|
||||
return c.Metrics.LockDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||
return c.Metrics.MainDims
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
|
||||
compactSurface := headerlayout.DropdownSurface{
|
||||
ContainerWidth: gtx.Constraints.Max.X,
|
||||
LeftInset: u.frameInsetPx,
|
||||
TopInset: u.frameInsetPx,
|
||||
}
|
||||
if c.ShowSyncMenu() {
|
||||
u.phoneSyncMenuVisible = true
|
||||
u.maybeLogHeaderMenuToggle("sync-visible", true)
|
||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
|
||||
u.phoneSyncMenuOrigin = placement.Origin
|
||||
u.phoneSyncMenuSize = placement.Size
|
||||
u.phoneSyncMenuCall = menuCall
|
||||
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
|
||||
}
|
||||
if c.ShowMainMenu() {
|
||||
u.phoneMainMenuVisible = true
|
||||
u.maybeLogHeaderMenuToggle("main-visible", true)
|
||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
|
||||
u.phoneMainMenuOrigin = placement.Origin
|
||||
u.phoneMainMenuSize = placement.Size
|
||||
u.phoneMainMenuCall = menuCall
|
||||
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
|
||||
}
|
||||
}
|
||||
|
||||
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
|
||||
menu := c.activeMenu()
|
||||
if menu == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
fullWidthGTX := gtx
|
||||
fullWidthGTX.Constraints.Min = image.Point{}
|
||||
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
|
||||
dims := layout.E.Layout(fullWidthGTX, menu)
|
||||
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
|
||||
}
|
||||
|
||||
type headerButtonBounds struct {
|
||||
SyncPrimary image.Rectangle
|
||||
SyncToggle image.Rectangle
|
||||
Lock image.Rectangle
|
||||
MainMenu image.Rectangle
|
||||
}
|
||||
|
||||
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
|
||||
return headerButtonBounds{
|
||||
SyncPrimary: bounds.SyncPrimary.Add(origin),
|
||||
SyncToggle: bounds.SyncToggle.Add(origin),
|
||||
Lock: bounds.Lock.Add(origin),
|
||||
MainMenu: bounds.MainMenu.Add(origin),
|
||||
}
|
||||
}
|
||||
|
||||
func (b headerButtonBounds) logLine(mode string) string {
|
||||
return fmt.Sprintf(
|
||||
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
|
||||
mode,
|
||||
formatHeaderRect(b.SyncPrimary),
|
||||
formatHeaderRect(b.SyncToggle),
|
||||
formatHeaderRect(b.Lock),
|
||||
formatHeaderRect(b.MainMenu),
|
||||
)
|
||||
}
|
||||
|
||||
func formatHeaderRect(rect image.Rectangle) string {
|
||||
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
|
||||
}
|
||||
|
||||
func (u *ui) topRightActionOrder() []string {
|
||||
if u.isVaultLocked() {
|
||||
return nil
|
||||
}
|
||||
return []string{"Sync", "Lock", "Menu"}
|
||||
}
|
||||
|
||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
if u.syncMenuVisibleOnPhone() {
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||
defer stack.Pop()
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
|
||||
})
|
||||
}
|
||||
if u.mainMenuVisibleOnPhone() {
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||
defer stack.Pop()
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
||||
})
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
cluster := u.newHeaderActionCluster(gtx)
|
||||
dims := cluster.layoutMenuRow(gtx)
|
||||
if dims.Size.Y == 0 {
|
||||
return dims
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
||||
|
||||
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
||||
|
||||
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
||||
|
||||
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
||||
|
||||
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
||||
|
||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
||||
if !u.usesCompactViewport() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
||||
if u.usesCompactViewport() {
|
||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
||||
}
|
||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
||||
}
|
||||
|
||||
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(widthDP))
|
||||
height := gtx.Dp(unit.Dp(heightDP))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
||||
height = gtx.Constraints.Max.Y
|
||||
}
|
||||
img := widget.Image{
|
||||
Src: src,
|
||||
Fit: widget.Contain,
|
||||
Position: layout.W,
|
||||
Scale: 1.0 / gtx.Metric.PxPerDp,
|
||||
}
|
||||
gtx.Constraints.Min = image.Point{}
|
||||
gtx.Constraints.Max = image.Pt(width, height)
|
||||
return img.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuWidget() layout.Widget {
|
||||
if !u.mainMenuOpen {
|
||||
return nil
|
||||
}
|
||||
return u.mainMenu
|
||||
}
|
||||
|
||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||
},
|
||||
}
|
||||
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||
icon := u.menuIcon
|
||||
if icon == nil {
|
||||
icon = u.settingsIcon
|
||||
}
|
||||
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
||||
}
|
||||
|
||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
||||
}
|
||||
|
||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||
return headerlayout.MenuActionWidth(gtx, rows)
|
||||
}
|
||||
|
||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||
return headerlayout.RightAlignedAction(gtx, width, child)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
func AnchoredMenuX(triggerWidth, menuWidth int) int {
|
||||
return triggerWidth - menuWidth
|
||||
}
|
||||
|
||||
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
|
||||
x := rowOriginX + triggerRightX - menuWidth
|
||||
if x < 0 {
|
||||
return 0
|
||||
}
|
||||
if x+menuWidth > containerWidth {
|
||||
return max(0, containerWidth-menuWidth)
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
type DropdownAnchor struct {
|
||||
TriggerRightX int
|
||||
TriggerBottomY int
|
||||
}
|
||||
|
||||
func (a DropdownAnchor) Point() image.Point {
|
||||
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
|
||||
}
|
||||
|
||||
type DropdownSurface struct {
|
||||
ContainerWidth int
|
||||
LeftInset int
|
||||
TopInset int
|
||||
}
|
||||
|
||||
type DropdownPlacement struct {
|
||||
Anchor DropdownAnchor
|
||||
Origin image.Point
|
||||
Size image.Point
|
||||
}
|
||||
|
||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
||||
menuGTX := gtx
|
||||
menuGTX.Constraints.Min = image.Point{}
|
||||
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
|
||||
return menuGTX
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
|
||||
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
|
||||
y := s.TopInset + anchor.TriggerBottomY
|
||||
return image.Pt(x, y)
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
|
||||
menuGTX := s.MenuConstraints(gtx)
|
||||
menuOps := op.Record(gtx.Ops)
|
||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
||||
menuCall := menuOps.Stop()
|
||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
||||
return DropdownPlacement{
|
||||
Anchor: anchor,
|
||||
Origin: menuOrigin,
|
||||
Size: menuDims.Size,
|
||||
}, menuCall
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
||||
placement, menuCall := s.Place(gtx, anchor, menu)
|
||||
stack := op.Offset(placement.Origin).Push(gtx.Ops)
|
||||
menuCall.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
type ActionMetrics struct {
|
||||
RowOriginX int
|
||||
Spacing int
|
||||
SyncInnerSpacing int
|
||||
RowDims layout.Dimensions
|
||||
SyncDims layout.Dimensions
|
||||
SyncPrimaryDims layout.Dimensions
|
||||
SyncToggleDims layout.Dimensions
|
||||
LockDims layout.Dimensions
|
||||
MainDims layout.Dimensions
|
||||
}
|
||||
|
||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
|
||||
type ActionBounds struct {
|
||||
SyncPrimary image.Rectangle
|
||||
SyncToggle image.Rectangle
|
||||
Lock image.Rectangle
|
||||
MainMenu image.Rectangle
|
||||
}
|
||||
|
||||
func (m ActionMetrics) Bounds() ActionBounds {
|
||||
top := 0
|
||||
syncLeft := m.RowOriginX
|
||||
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
|
||||
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
|
||||
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
|
||||
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
|
||||
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
|
||||
mainLeft := lock.Max.X + m.Spacing
|
||||
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
|
||||
return ActionBounds{
|
||||
SyncPrimary: syncPrimary,
|
||||
SyncToggle: syncToggle,
|
||||
Lock: lock,
|
||||
MainMenu: mainMenu,
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
||||
macro := op.Record(gtx.Ops)
|
||||
contentDims := w(measureGTX)
|
||||
_ = macro.Stop()
|
||||
if logger != nil {
|
||||
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
|
||||
}
|
||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
||||
maxWidth := gtx.Constraints.Max.X
|
||||
if maxWidth > 0 && width > maxWidth {
|
||||
width = maxWidth
|
||||
}
|
||||
if width > 0 {
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
}
|
||||
dims := card(gtx, w)
|
||||
if logger != nil {
|
||||
logger("intrinsic-card", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||
width := 0
|
||||
for _, row := range rows {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := row(measureGTX)
|
||||
_ = macro.Stop()
|
||||
if dims.Size.X > width {
|
||||
width = dims.Size.X
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||
if width <= 0 {
|
||||
return child(gtx)
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return layout.E.Layout(gtx, child)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package header
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
)
|
||||
|
||||
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
||||
if logger != nil {
|
||||
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
|
||||
}
|
||||
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
|
||||
for i, row := range rows {
|
||||
if i > 0 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
}
|
||||
current := row
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
|
||||
}))
|
||||
}
|
||||
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
if logger != nil {
|
||||
logger("rows", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}, card, logger)
|
||||
if logger != nil {
|
||||
logger("card", gtx.Constraints, dims)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
|
||||
btn := material.IconButton(theme, click, icon, "Menu")
|
||||
if open {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = selectedColor
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
|
||||
spacing := unit.Dp(4)
|
||||
if u.usesCompactViewport() {
|
||||
spacing = unit.Dp(3)
|
||||
}
|
||||
var primaryDims layout.Dimensions
|
||||
var toggleDims layout.Dimensions
|
||||
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
||||
return primaryDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
toggleDims = u.syncMenuToggle(gtx)
|
||||
return toggleDims
|
||||
}),
|
||||
)
|
||||
return groupDims, primaryDims, toggleDims
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
||||
if u.syncMenuOpen {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
if u.usesCompactViewport() {
|
||||
btn.Size = unit.Dp(16)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(7))
|
||||
}
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuWidget() layout.Widget {
|
||||
if !u.syncMenuOpen {
|
||||
return nil
|
||||
}
|
||||
return u.syncMenu
|
||||
}
|
||||
|
||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
||||
model := u.buildSyncMenuModel()
|
||||
profiles := u.availableRemoteProfiles()
|
||||
credentials := u.availableRemoteCredentialEntries()
|
||||
if len(u.vaultRemoteProfileClicks) < len(profiles) {
|
||||
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
|
||||
}
|
||||
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
|
||||
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
|
||||
}
|
||||
actionRows := u.syncMenuActionRows(model)
|
||||
actionWidth := menuActionWidth(gtx, actionRows)
|
||||
menu := func(gtx layout.Context) layout.Dimensions {
|
||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
||||
})
|
||||
}
|
||||
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
|
||||
if reserveWidth <= 0 {
|
||||
return menu(gtx)
|
||||
}
|
||||
return layout.Flex{}.Layout(gtx,
|
||||
layout.Rigid(menu),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
if u.usesCompactViewport() {
|
||||
spacing = gtx.Dp(unit.Dp(8))
|
||||
}
|
||||
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
|
||||
lockOps := op.Record(gtx.Ops)
|
||||
lockDims := func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||
return btn.Layout(gtx)
|
||||
}(measureGTX)
|
||||
_ = lockOps.Stop()
|
||||
|
||||
menuOps := op.Record(gtx.Ops)
|
||||
menuDims := u.mainMenuButtonGroup(measureGTX)
|
||||
_ = menuOps.Stop()
|
||||
|
||||
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
||||
},
|
||||
}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
||||
if model.ShowSaveCurrentBinding {
|
||||
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
||||
rows := []layout.FlexChild{}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
)
|
||||
}))
|
||||
}
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
|
||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, click, label)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
}
|
||||
if !model.ShowSelectors {
|
||||
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
|
||||
} else {
|
||||
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
|
||||
}
|
||||
if _, ok := u.selectedVaultRemoteProfile(); ok {
|
||||
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
|
||||
rows = append(rows,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
summary := model.SavedBindingSummary
|
||||
if !summary.OK {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
|
||||
return []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
||||
for i, profile := range profiles {
|
||||
i := i
|
||||
profile := profile
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteProfileID == profile.ID
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
for i, entry := range credentials {
|
||||
i := i
|
||||
entry := entry
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
|
||||
label := entry.Title
|
||||
if entry.Username != "" {
|
||||
label += " · " + entry.Username
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
|
||||
model := syncmodel.MenuModel{
|
||||
HasOpenVault: u.hasOpenVault(),
|
||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
RemotePassword: u.remotePassword.Text(),
|
||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||
}
|
||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
||||
return model
|
||||
}
|
||||
|
||||
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
|
||||
profile, ok := u.selectedVaultRemoteProfile()
|
||||
if !ok {
|
||||
return syncmodel.MenuBindingSummary{}
|
||||
}
|
||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
||||
if !ok {
|
||||
return syncmodel.MenuBindingSummary{}
|
||||
}
|
||||
credentialLabel := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
syncLabel := "Sync manually when you choose Use Remote Sync."
|
||||
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
||||
syncLabel = "Syncs automatically on open and save."
|
||||
}
|
||||
return syncmodel.MenuBindingSummary{
|
||||
ProfileLabel: profile.Name,
|
||||
CredentialLabel: credentialLabel,
|
||||
SyncLabel: syncLabel,
|
||||
OK: true,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package lifecycle
|
||||
|
||||
type OpenIntent string
|
||||
|
||||
const (
|
||||
OpenIntentNone OpenIntent = ""
|
||||
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
||||
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
||||
)
|
||||
@@ -1,778 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func (u *ui) createVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.CreateVault(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.lifecycleMode == "local" {
|
||||
u.selectedVaultRemoteProfileID = ""
|
||||
u.selectedVaultRemoteCredentialEntryID = ""
|
||||
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||
u.remoteBaseURL.SetText("")
|
||||
u.remotePath.SetText("")
|
||||
u.remoteUsername.SetText("")
|
||||
u.remotePassword.SetText("")
|
||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(u.saveAsTargetPath())
|
||||
u.noteRecentVault(u.saveAsTargetPath())
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openVaultAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.OpenVault(path, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenVaultAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open vault", u.openVaultAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
u.lastLifecycleAction = "open vault"
|
||||
u.runBackgroundAction("open vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareLocalOpen(path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(prepared)
|
||||
u.noteRecentVault(path)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.restoreRecentVaultGroup(path)
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||
}
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
u.applyPendingLifecycleOpenIntent()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if path == "" {
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
if hasBoundRecentRemote(u.recentRemotes, path) {
|
||||
return "Open Vault And Open Remote Sync Settings"
|
||||
}
|
||||
return "Open Vault And Set Up Remote Sync"
|
||||
}
|
||||
|
||||
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
switch {
|
||||
case path == "":
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
case hasBoundRecentRemote(u.recentRemotes, path):
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
||||
default:
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
||||
}
|
||||
u.startOpenVaultAction()
|
||||
}
|
||||
|
||||
func (u *ui) applyPendingLifecycleOpenIntent() {
|
||||
intent := u.pendingLifecycleOpenIntent
|
||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||
switch intent {
|
||||
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
||||
u.openRemoteSyncSetupDialog()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) saveAction() error {
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsAction() error {
|
||||
path := u.saveAsTargetPath()
|
||||
if err := u.state.SaveAs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
u.vaultPath.SetText(path)
|
||||
u.noteRecentVault(path)
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
strings.TrimSpace(u.remotePath.Text()),
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startOpenRemoteAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("open remote vault", u.openRemoteAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}
|
||||
remotePath := strings.TrimSpace(u.remotePath.Text())
|
||||
u.lastLifecycleAction = "open remote vault"
|
||||
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
||||
binding, bindingOK := u.selectedVaultRemoteBinding()
|
||||
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
||||
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := binding.Resolve(preparedLocal.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}, resolved.Profile.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedLocalOpen(preparedLocal)
|
||||
u.vaultPath.SetText(binding.LocalVaultPath)
|
||||
u.noteRecentVault(binding.LocalVaultPath)
|
||||
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
||||
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
if u.hasOpenVault() {
|
||||
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
client = webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
remotePath = resolved.Profile.Path
|
||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||
u.remotePath.SetText(resolved.Profile.Path)
|
||||
}
|
||||
}
|
||||
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedRemoteOpen(prepared)
|
||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.noteRecentRemote(
|
||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
remotePath,
|
||||
)
|
||||
u.resetPasswordPeek()
|
||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lockAction() error {
|
||||
u.clearMasterPassword()
|
||||
if err := u.state.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.requestMasterPassFocus = true
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.resetPasswordPeek()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) unlockAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Unlock(key); err != nil {
|
||||
return err
|
||||
}
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startUnlockAction() {
|
||||
manager, ok := u.state.Session.(*session.Manager)
|
||||
if !ok {
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
return
|
||||
}
|
||||
key, err := u.currentMasterKey()
|
||||
u.clearMasterPassword()
|
||||
if err != nil {
|
||||
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
||||
u.requestMasterPassFocus = true
|
||||
return
|
||||
}
|
||||
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
||||
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
||||
prepared, err := session.PrepareUnlock(encoded, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
manager.ApplyPreparedUnlock(prepared)
|
||||
u.resetPasswordPeek()
|
||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||
u.loadSecuritySettingsFromSession()
|
||||
u.editingEntry = false
|
||||
u.filter()
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) changeMasterKeyAction() error {
|
||||
key, err := u.currentMasterKey()
|
||||
defer u.clearMasterPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.state.ChangeMasterKey(key)
|
||||
}
|
||||
|
||||
func (u *ui) loadSecuritySettingsFromSession() {
|
||||
settings, err := u.state.SecuritySettings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.securityCipher.SetText(settings.Cipher)
|
||||
u.securityKDF.SetText(settings.KDF)
|
||||
}
|
||||
|
||||
func (u *ui) clearMasterPassword() {
|
||||
u.masterPassword.SetText("")
|
||||
}
|
||||
|
||||
func (u *ui) synchronizeAction() error {
|
||||
if err := u.state.Synchronize(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.syncMenuOpen = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) openAdvancedSyncDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||
u.syncSourceMode = u.syncDefaultSourceMode
|
||||
u.syncDirection = u.syncDefaultDirection
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
}
|
||||
|
||||
func (u *ui) openRemoteSyncSetupDialog() {
|
||||
u.syncDialogOpen = true
|
||||
u.syncMenuOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.syncDialogList.Position = layout.Position{}
|
||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||
u.syncSourceMode = syncSourceRemote
|
||||
u.syncDirection = syncDirectionPush
|
||||
u.syncSetupAutomatic.Value = true
|
||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||
}
|
||||
u.syncSavedRemoteBindingSelection()
|
||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
||||
u.syncSetupAutomatic.Value = false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) clearSyncLocalImport() {
|
||||
u.syncLocalImportName = ""
|
||||
u.syncLocalImportContent = nil
|
||||
}
|
||||
|
||||
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||
name := strings.TrimSpace(u.syncLocalImportName)
|
||||
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||
}
|
||||
|
||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||
switch mode {
|
||||
case syncSourceRemote:
|
||||
return syncSourceRemote
|
||||
default:
|
||||
return syncSourceLocal
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
||||
switch direction {
|
||||
case syncDirectionPush:
|
||||
return syncDirectionPush
|
||||
default:
|
||||
return syncDirectionPull
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncAction() error {
|
||||
switch u.syncDirection {
|
||||
case syncDirectionPush:
|
||||
return u.advancedSyncToAction()
|
||||
default:
|
||||
return u.advancedSyncFromAction()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncFromAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
client := webdav.Client{
|
||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose sync path", func() error {
|
||||
u.clearSyncLocalImport()
|
||||
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||
})
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label := "Selected Android vault"
|
||||
return func() error {
|
||||
u.syncLocalImportName = label
|
||||
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||
u.syncLocalPath.SetText(label)
|
||||
return nil
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
||||
if named, ok := file.(interface{ Name() string }); ok {
|
||||
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
fallback = filepath.Base(strings.TrimSpace(fallback))
|
||||
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
||||
return "selected-vault.kdbx"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (u *ui) startChooseVaultPathAction() {
|
||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := pickedDocumentName(file, "selected-vault.kdbx")
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction(name, content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) startImportSharedVaultAction() {
|
||||
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||
return
|
||||
}
|
||||
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||
if err != nil {
|
||||
if errors.Is(err, explorer.ErrUserDecline) {
|
||||
return func() error { return nil }, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) advancedSyncToAction() error {
|
||||
switch u.syncSourceMode {
|
||||
case syncSourceRemote:
|
||||
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
||||
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
||||
client := webdav.Client{
|
||||
BaseURL: baseURL,
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
}
|
||||
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
u.showStatusMessage("Remote sync is set up for this vault.")
|
||||
}
|
||||
default:
|
||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.syncDialogOpen = false
|
||||
u.showSyncPassword = false
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
remotePath = strings.TrimSpace(remotePath)
|
||||
if baseURL == "" || remotePath == "" {
|
||||
return fmt.Errorf("remote setup requires base URL and path")
|
||||
}
|
||||
input := appstate.RemoteBindingInput{
|
||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
||||
BaseURL: baseURL,
|
||||
RemotePath: remotePath,
|
||||
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||
CredentialTitle: "WebDAV Sign-In" + func() string {
|
||||
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
||||
return " · " + user
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||
Password: u.syncRemotePassword.Text(),
|
||||
CredentialPath: append([]string(nil), u.currentPath...),
|
||||
SyncMode: u.syncSetupMode(),
|
||||
}
|
||||
binding, err := u.state.ConfigureRemoteBinding(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||
u.remoteBaseURL.SetText(baseURL)
|
||||
u.remotePath.SetText(remotePath)
|
||||
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
||||
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
||||
u.noteRecentRemote(baseURL, remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) saveAsTargetPath() string {
|
||||
path := strings.TrimSpace(u.saveAsPath.Text())
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
return u.defaultSaveAsPath
|
||||
}
|
||||
|
||||
func (u *ui) importedVaultDestination(name string) string {
|
||||
baseTarget := u.saveAsTargetPath()
|
||||
baseDir := filepath.Dir(baseTarget)
|
||||
baseName := filepath.Base(strings.TrimSpace(name))
|
||||
switch {
|
||||
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||
return baseTarget
|
||||
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||
return filepath.Join(baseDir, baseName)
|
||||
default:
|
||||
return baseTarget
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) consumePendingSharedVaultImport() {
|
||||
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
name := "shared-vault.kdbx"
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
if rawName, err := os.ReadFile(namePath); err == nil {
|
||||
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
||||
name = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||
_ = os.Remove(namePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||
target := u.importedVaultDestination(name)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
u.lifecycleMode = "local"
|
||||
u.vaultPath.SetText(target)
|
||||
u.noteRecentVault(target)
|
||||
u.state.ErrorMessage = ""
|
||||
u.state.StatusMessage = ""
|
||||
u.requestMasterPassFocus = true
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) currentShareableVaultPath() string {
|
||||
return strings.TrimSpace(u.vaultPath.Text())
|
||||
}
|
||||
|
||||
func (u *ui) shareCurrentVaultAction() error {
|
||||
if u.vaultSharer == nil {
|
||||
return fmt.Errorf("vault sharing is not available on this platform")
|
||||
}
|
||||
path := u.currentShareableVaultPath()
|
||||
if path == "" {
|
||||
return errors.New(errVaultPathRequired)
|
||||
}
|
||||
if err := u.state.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package layout
|
||||
|
||||
type TopSection string
|
||||
|
||||
const (
|
||||
TopSearch TopSection = "search"
|
||||
TopNavigation TopSection = "navigation"
|
||||
TopPath TopSection = "path"
|
||||
TopGroup TopSection = "group"
|
||||
TopGroupTools TopSection = "group_tools"
|
||||
TopPrimary TopSection = "primary"
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
package list
|
||||
|
||||
type EntriesSectionState struct {
|
||||
Path []string
|
||||
SearchQuery string
|
||||
SelectedEntryID string
|
||||
Editing bool
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Werror
|
||||
#cgo LDFLAGS: -landroid
|
||||
|
||||
#include <android/log.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func LogInfo(tag, msg string) {
|
||||
ctag := C.CString(tag)
|
||||
defer C.free(unsafe.Pointer(ctag))
|
||||
cmsg := C.CString(msg)
|
||||
defer C.free(unsafe.Pointer(cmsg))
|
||||
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Werror
|
||||
#cgo LDFLAGS: -landroid
|
||||
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
||||
return (*env)->GetObjectClass(env, obj);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
||||
return (*env)->CallObjectMethodA(env, obj, method, args);
|
||||
}
|
||||
|
||||
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
||||
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
||||
}
|
||||
|
||||
static jvalue jni_ValueObject(jobject obj) {
|
||||
jvalue value;
|
||||
value.l = obj;
|
||||
return value;
|
||||
}
|
||||
|
||||
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
||||
return (*env)->ExceptionOccurred(env);
|
||||
}
|
||||
|
||||
static void jni_ExceptionClear(JNIEnv *env) {
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
||||
return (*env)->NewString(env, unicodeChars, len);
|
||||
}
|
||||
|
||||
static int jni_IsNull(jobject obj) {
|
||||
return obj == NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"gioui.org/app"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type androidVaultSharer struct{}
|
||||
|
||||
//go:linkname gioJavaVM gioui.org/app.javaVM
|
||||
func gioJavaVM() *C.JavaVM
|
||||
|
||||
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return androidVaultSharer{}
|
||||
}
|
||||
|
||||
func (androidVaultSharer) ShareVault(path, title string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("vault path is required")
|
||||
}
|
||||
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
||||
if C.jni_IsNull(ctx) != 0 {
|
||||
return fmt.Errorf("android app context is not available")
|
||||
}
|
||||
var callErr error
|
||||
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
||||
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
||||
if err != nil {
|
||||
callErr = err
|
||||
return
|
||||
}
|
||||
methodName := cString("shareVault")
|
||||
defer C.free(unsafe.Pointer(methodName))
|
||||
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
||||
defer C.free(unsafe.Pointer(methodSig))
|
||||
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
||||
if method == nil {
|
||||
callErr = androidJNIError(env, "resolve shareVault method")
|
||||
if callErr == nil {
|
||||
callErr = fmt.Errorf("resolve shareVault method")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jPath := androidJavaString(env, path)
|
||||
jTitle := androidJavaString(env, title)
|
||||
args := [3]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(ctx)
|
||||
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
||||
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
||||
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
||||
callErr = androidJNIError(env, "share vault")
|
||||
})
|
||||
return callErr
|
||||
}
|
||||
|
||||
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
||||
var zeroClass C.jclass
|
||||
contextClass := C.jni_GetObjectClass(env, ctx)
|
||||
getClassLoaderName := cString("getClassLoader")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderName))
|
||||
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
||||
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
||||
if getClassLoader == nil {
|
||||
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
||||
}
|
||||
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
||||
if err := androidJNIError(env, "load class loader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(classLoader) != 0 {
|
||||
return zeroClass, fmt.Errorf("android class loader is nil")
|
||||
}
|
||||
|
||||
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
||||
loadClassName := cString("loadClass")
|
||||
defer C.free(unsafe.Pointer(loadClassName))
|
||||
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
||||
defer C.free(unsafe.Pointer(loadClassSig))
|
||||
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
||||
if loadClass == nil {
|
||||
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve loadClass")
|
||||
}
|
||||
|
||||
jClassName := androidJavaString(env, name)
|
||||
args := [1]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
||||
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
||||
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(loaded) != 0 {
|
||||
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
||||
}
|
||||
return C.jclass(loaded), nil
|
||||
}
|
||||
|
||||
func androidJNIError(env *C.JNIEnv, action string) error {
|
||||
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
||||
C.jni_ExceptionClear(env)
|
||||
return fmt.Errorf("%s: Java exception", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
||||
chars := utf16.Encode([]rune(s))
|
||||
if len(chars) == 0 {
|
||||
return C.jni_NewString(env, nil, 0)
|
||||
}
|
||||
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
||||
}
|
||||
|
||||
func cString(value string) *C.char {
|
||||
return C.CString(value)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !android
|
||||
|
||||
package platform
|
||||
|
||||
import "log"
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return nil
|
||||
}
|
||||
|
||||
func LogInfo(tag, msg string) {
|
||||
log.Printf("%s: %s", tag, msg)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gioclipboard "gioui.org/io/clipboard"
|
||||
"gioui.org/layout"
|
||||
|
||||
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
)
|
||||
|
||||
type VaultSharer interface {
|
||||
ShareVault(path, title string) error
|
||||
}
|
||||
|
||||
type clipboardCommandWriter struct {
|
||||
mu sync.Mutex
|
||||
pending []string
|
||||
invalidate func()
|
||||
}
|
||||
|
||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
if strings.EqualFold(goos, "android") {
|
||||
return &clipboardCommandWriter{invalidate: invalidate}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
commandWriter.Process(gtx)
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) WriteText(text string) error {
|
||||
w.mu.Lock()
|
||||
w.pending = append(w.pending, text)
|
||||
w.mu.Unlock()
|
||||
if w.invalidate != nil {
|
||||
w.invalidate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
|
||||
for _, text := range w.drain() {
|
||||
gtx.Execute(gioclipboard.WriteCmd{
|
||||
Type: "application/text",
|
||||
Data: io.NopCloser(strings.NewReader(text)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *clipboardCommandWriter) drain() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := append([]string(nil), w.pending...)
|
||||
w.pending = nil
|
||||
return pending
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := NewClipboardWriter("android", nil)
|
||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &clipboardCommandWriter{}
|
||||
if err := writer.WriteText("username"); err != nil {
|
||||
t.Fatalf("WriteText(username) error = %v", err)
|
||||
}
|
||||
if err := writer.WriteText("password"); err != nil {
|
||||
t.Fatalf("WriteText(password) error = %v", err)
|
||||
}
|
||||
|
||||
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
|
||||
t.Fatalf("drain() = %v, want [username password]", got)
|
||||
}
|
||||
if got := writer.drain(); got != nil {
|
||||
t.Fatalf("drain() after flush = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/x/explorer"
|
||||
"git.julianfamily.org/keepassgo/internal/api"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func Main() {
|
||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
||||
flag.Parse()
|
||||
|
||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
||||
|
||||
width := unit.Dp(1180)
|
||||
height := unit.Dp(760)
|
||||
if strings.EqualFold(resolvedMode, "phone") {
|
||||
width = unit.Dp(412)
|
||||
height = unit.Dp(915)
|
||||
}
|
||||
|
||||
go func() {
|
||||
w := new(app.Window)
|
||||
options := []app.Option{app.Title(productName)}
|
||||
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
||||
options = append(options, app.Size(width, height))
|
||||
}
|
||||
w.Option(options...)
|
||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func defaultGRPCAddr(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
return "127.0.0.1:47777"
|
||||
}
|
||||
|
||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||
var ops op.Ops
|
||||
manager := &session.Manager{}
|
||||
ui := newUIWithSession(mode, manager, paths)
|
||||
ui.fileExplorer = explorer.NewExplorer(w)
|
||||
ui.invalidate = w.Invalidate
|
||||
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||
if err != nil {
|
||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||
} else if host != nil {
|
||||
ui.apiHost = host
|
||||
ui.auditLog = host.Server().AuditLog()
|
||||
ui.grpcAddress = host.Address()
|
||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||
defer func() { _ = host.Stop() }()
|
||||
}
|
||||
for {
|
||||
e := w.Event()
|
||||
ui.fileExplorer.ListenEvents(e)
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
ui.processBackgroundActions()
|
||||
ui.layout(gtx)
|
||||
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
server *api.Server
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
||||
if m == nil || m.server == nil {
|
||||
return nil
|
||||
}
|
||||
return m.server.ApprovalBroker().Pending()
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||
if m == nil || m.server == nil {
|
||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
||||
}
|
||||
return m.server.ResolveApproval(id, outcome)
|
||||
}
|
||||
|
||||
type uiSession struct {
|
||||
model vault.Model
|
||||
locked bool
|
||||
}
|
||||
|
||||
func (s *uiSession) HasVault() bool {
|
||||
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsLocked() bool {
|
||||
return s.locked
|
||||
}
|
||||
|
||||
func (s *uiSession) IsRemote() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *uiSession) Current() (vault.Model, error) {
|
||||
if s.locked {
|
||||
return vault.Model{}, session.ErrLocked
|
||||
}
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
|
||||
func (s *uiSession) Lock() error {
|
||||
s.locked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *uiSession) Unlock(vault.MasterKey) error {
|
||||
if !s.locked {
|
||||
return nil
|
||||
}
|
||||
s.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickExistingFile() (string, error) {
|
||||
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
||||
}
|
||||
|
||||
func runFilePicker(name string, args ...string) (string, error) {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsePickedFilePath(output)
|
||||
}
|
||||
|
||||
func parsePickedFilePath(output []byte) (string, error) {
|
||||
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("file picker did not return a path")
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||
|
||||
contrastStandard = settingsmodel.ContrastStandard
|
||||
contrastHigh = settingsmodel.ContrastHigh
|
||||
|
||||
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||
)
|
||||
|
||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||
|
||||
type settingsFile struct {
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Debug debugSettings `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettings struct {
|
||||
SourceDefault string `json:"sourceDefault,omitempty"`
|
||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||
}
|
||||
|
||||
type debugSettings struct {
|
||||
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettingsDraft struct {
|
||||
SourceDefault syncSourceMode
|
||||
DirectionDefault syncDirection
|
||||
}
|
||||
|
||||
type settingsDraft struct {
|
||||
Accessibility accessibilityPreferences
|
||||
Sync syncSettingsDraft
|
||||
Debug debugSettings
|
||||
}
|
||||
|
||||
type legacySyncPreferences struct {
|
||||
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
|
||||
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
|
||||
}
|
||||
|
||||
type choiceSpec struct {
|
||||
Click *widget.Clickable
|
||||
Label string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type focusAppearance struct {
|
||||
BorderColor color.NRGBA
|
||||
OutlineColor color.NRGBA
|
||||
OutlineWidth int
|
||||
MinHeight int
|
||||
}
|
||||
|
||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||
return settingsmodel.DefaultAccessibilityPreferences()
|
||||
}
|
||||
|
||||
func displayDensityForDenseLayout(dense bool) string {
|
||||
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||
}
|
||||
|
||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||
}
|
||||
|
||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
normalized := normalizeAccessibilityPreferences(prefs)
|
||||
u.denseLayout = normalized.DisplayDensity == displayDensityDense
|
||||
u.accessibilityPrefs = normalized
|
||||
}
|
||||
|
||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
appearance := focusAppearance{
|
||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||
OutlineColor: color.NRGBA{A: 0},
|
||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||
MinHeight: metric.Dp(unit.Dp(44)),
|
||||
}
|
||||
if prefs.DisplayDensity == displayDensityComfortable {
|
||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||
}
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||
}
|
||||
if focused {
|
||||
appearance.BorderColor = accentColor
|
||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||
}
|
||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||
}
|
||||
}
|
||||
return appearance
|
||||
}
|
||||
|
||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||
text = accentColor
|
||||
if prefs.Contrast == contrastHigh {
|
||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
}
|
||||
if focused {
|
||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||
}
|
||||
}
|
||||
return background, text
|
||||
}
|
||||
|
||||
func (u *ui) accessibilityLabel(id focusID) string {
|
||||
switch {
|
||||
case id == focusSearch:
|
||||
return "Search vault"
|
||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||
index := focusIndex(id)
|
||||
crumbs := u.breadcrumbLabels()
|
||||
if index >= 0 && index < len(crumbs) {
|
||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||
}
|
||||
case strings.HasPrefix(string(id), "list:"):
|
||||
index := focusIndex(id)
|
||||
if index >= 0 && index < len(u.visible) {
|
||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||
}
|
||||
case strings.HasPrefix(string(id), "detail:"):
|
||||
name := strings.TrimPrefix(string(id), "detail:")
|
||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
width := appearance.OutlineWidth
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
func (u *ui) isFocused(id focusID) bool {
|
||||
return u.keyboardFocus == id
|
||||
}
|
||||
|
||||
func detailFieldLabel(field detailField) string {
|
||||
return editormodel.Label(field)
|
||||
}
|
||||
|
||||
func (u *ui) loadSettingsDraft() {
|
||||
u.settingsDraft = settingsDraft{
|
||||
Accessibility: accessibilityPreferences{
|
||||
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
|
||||
Contrast: u.accessibilityPrefs.Contrast,
|
||||
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
|
||||
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
|
||||
},
|
||||
Sync: syncSettingsDraft{
|
||||
SourceDefault: u.syncDefaultSourceMode,
|
||||
DirectionDefault: u.syncDefaultDirection,
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}
|
||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||
}
|
||||
|
||||
func (u *ui) saveSecuritySettingsAction() error {
|
||||
if err := u.applySecuritySettingsLive(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.securityDialogOpen = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) applySecuritySettingsLive() error {
|
||||
settings := vault.SecuritySettings{
|
||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||
}
|
||||
if err := u.state.ConfigureSecurity(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||
}
|
||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||
if !u.debugLogHeaderBounds {
|
||||
u.lastHeaderBoundsLog = ""
|
||||
}
|
||||
u.applySettingsFormToPreferences()
|
||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||
u.saveSettings()
|
||||
u.saveUIPreferences()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ui) loadSettings() {
|
||||
u.syncDefaultSourceMode = syncSourceLocal
|
||||
u.syncDefaultDirection = syncDirectionPull
|
||||
|
||||
if strings.TrimSpace(u.settingsPath) != "" {
|
||||
content, err := os.ReadFile(u.settingsPath)
|
||||
if err == nil {
|
||||
var settings settingsFile
|
||||
if json.Unmarshal(content, &settings) == nil {
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||
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),
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
line := bounds.logLine(u.mode)
|
||||
if line == u.lastHeaderBoundsLog {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", line)
|
||||
u.lastHeaderBoundsLog = line
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
|
||||
}
|
||||
|
||||
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
|
||||
if !u.debugLogHeaderBounds {
|
||||
return
|
||||
}
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf(
|
||||
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
|
||||
menu,
|
||||
placement.Anchor.TriggerRightX,
|
||||
placement.Anchor.TriggerBottomY,
|
||||
placement.Origin.X,
|
||||
placement.Origin.Y,
|
||||
placement.Size.X,
|
||||
placement.Size.Y,
|
||||
surface.ContainerWidth,
|
||||
surface.LeftInset,
|
||||
surface.TopInset,
|
||||
))
|
||||
}
|
||||
|
||||
func (u *ui) showStatusMessage(message string) {
|
||||
u.state.StatusMessage = message
|
||||
if u.accessibilityPrefs.ReducedMotion {
|
||||
u.statusExpiresAt = time.Time{}
|
||||
return
|
||||
}
|
||||
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
|
||||
}
|
||||
|
||||
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
|
||||
return sectionCard(gtx, u.theme, title, detail, body)
|
||||
}
|
||||
|
||||
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(th, unit.Sp(12), title)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(th, unit.Sp(13), body)
|
||||
lbl.Color = th.Palette.Fg
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
|
||||
children := make([]layout.FlexChild, 0, len(choices)*2)
|
||||
for i, choice := range choices {
|
||||
if i > 0 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
|
||||
}
|
||||
current := choice
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
|
||||
}
|
||||
|
||||
type listRowColors struct {
|
||||
Title color.NRGBA
|
||||
Meta color.NRGBA
|
||||
Secondary color.NRGBA
|
||||
Divider color.NRGBA
|
||||
Fill color.NRGBA
|
||||
Edge color.NRGBA
|
||||
}
|
||||
|
||||
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
|
||||
colors := listRowColors{
|
||||
Title: accentColor,
|
||||
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
|
||||
Secondary: mutedColor,
|
||||
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
|
||||
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
|
||||
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
|
||||
}
|
||||
if selected {
|
||||
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
|
||||
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
|
||||
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
|
||||
}
|
||||
if recycleBin {
|
||||
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
|
||||
}
|
||||
if focused && !selected {
|
||||
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
|
||||
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
|
||||
}
|
||||
if u.accessibilityPrefs.Contrast == contrastHigh {
|
||||
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
|
||||
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
|
||||
if focused || selected {
|
||||
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
}
|
||||
}
|
||||
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
|
||||
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
|
||||
}
|
||||
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
|
||||
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
|
||||
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package settings
|
||||
|
||||
type AccessibilityPreferences struct {
|
||||
DisplayDensity string
|
||||
Contrast string
|
||||
ReducedMotion bool
|
||||
KeyboardFocus string
|
||||
}
|
||||
|
||||
const (
|
||||
DisplayDensityDense = "dense"
|
||||
DisplayDensityComfortable = "comfortable"
|
||||
ContrastStandard = "standard"
|
||||
ContrastHigh = "high"
|
||||
KeyboardFocusStandard = "standard"
|
||||
KeyboardFocusProminent = "prominent"
|
||||
)
|
||||
|
||||
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
||||
return AccessibilityPreferences{
|
||||
DisplayDensity: DisplayDensityDense,
|
||||
Contrast: ContrastStandard,
|
||||
KeyboardFocus: KeyboardFocusStandard,
|
||||
}
|
||||
}
|
||||
|
||||
func DisplayDensityForDenseLayout(dense bool) string {
|
||||
if dense {
|
||||
return DisplayDensityDense
|
||||
}
|
||||
return DisplayDensityComfortable
|
||||
}
|
||||
|
||||
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
||||
normalized := DefaultAccessibilityPreferences()
|
||||
switch prefs.DisplayDensity {
|
||||
case DisplayDensityDense, DisplayDensityComfortable:
|
||||
normalized.DisplayDensity = prefs.DisplayDensity
|
||||
}
|
||||
switch prefs.Contrast {
|
||||
case ContrastStandard, ContrastHigh:
|
||||
normalized.Contrast = prefs.Contrast
|
||||
}
|
||||
switch prefs.KeyboardFocus {
|
||||
case KeyboardFocusStandard, KeyboardFocusProminent:
|
||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||
}
|
||||
normalized.ReducedMotion = prefs.ReducedMotion
|
||||
return normalized
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package sync
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
||||
|
||||
type SourceMode string
|
||||
|
||||
const (
|
||||
SourceLocal SourceMode = "local"
|
||||
SourceRemote SourceMode = "remote"
|
||||
)
|
||||
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionPull Direction = "pull"
|
||||
DirectionPush Direction = "push"
|
||||
)
|
||||
|
||||
type DialogPurpose string
|
||||
|
||||
const (
|
||||
DialogPurposeAdvanced DialogPurpose = "advanced"
|
||||
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
||||
)
|
||||
|
||||
type MenuModel struct {
|
||||
HasOpenVault bool
|
||||
HasSelectedBinding bool
|
||||
ShowSelectors bool
|
||||
ShowShare bool
|
||||
ShowSaveCurrentBinding bool
|
||||
SavedBindingSummary MenuBindingSummary
|
||||
RemoteBaseURL string
|
||||
RemotePath string
|
||||
RemoteUsername string
|
||||
RemotePassword string
|
||||
SelectedVaultSyncMode appstate.SyncMode
|
||||
}
|
||||
|
||||
type MenuBindingSummary struct {
|
||||
ProfileLabel string
|
||||
CredentialLabel string
|
||||
SyncLabel string
|
||||
OK bool
|
||||
}
|
||||
|
||||
func (m MenuModel) SavedBindingHeading() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use this vault's saved remote sync target"
|
||||
}
|
||||
return "Use a saved remote profile from this vault"
|
||||
}
|
||||
|
||||
func (m MenuModel) OpenSelectedButtonLabel() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use Remote Sync"
|
||||
}
|
||||
return "Open Saved Remote"
|
||||
}
|
||||
|
||||
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
||||
|
||||
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
||||
|
||||
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
||||
|
||||
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
||||
return m.HasOpenVault && !m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
||||
|
||||
func (m MenuModel) ActionLabels() []string {
|
||||
labels := []string{"Open Advanced Sync"}
|
||||
if m.ShowRemoteSyncSetupShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
||||
}
|
||||
if m.ShowDirectRemoteSyncShortcut() {
|
||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoteSyncSettingsShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoveRemoteSyncShortcut() {
|
||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
||||
return "Bind this local vault to the current remote target"
|
||||
}
|
||||
|
||||
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
||||
|
||||
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
||||
if purpose == DialogPurposeRemoteSetup {
|
||||
return "Push this local vault to a WebDAV target and save that target for future sync."
|
||||
}
|
||||
sourceLabel := "another local vault file"
|
||||
if source == SourceRemote {
|
||||
sourceLabel = "another WebDAV-backed vault"
|
||||
}
|
||||
action := "Pull changes from"
|
||||
if direction == DirectionPush {
|
||||
action = "Push the current vault into"
|
||||
}
|
||||
return action + " " + sourceLabel + "."
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(620))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||
}
|
||||
if width < 1 {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return card(gtx, u.syncDialogContent)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
||||
}
|
||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.syncSourceMode == syncSourceRemote {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncPasswordField(gtx)
|
||||
}),
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
||||
check.Color = accentColor
|
||||
return check.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
if len(matchingCredentials) > 0 {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
for i, entry := range matchingCredentials {
|
||||
i := i
|
||||
entry := entry
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
label += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
field := func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.outlinedFieldState(gtx, false, field)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
pkgbase = keepassgo-git
|
||||
pkgdesc = KeePass-compatible password manager written in Go
|
||||
pkgver = r165.1c72a50
|
||||
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
|
||||
@@ -1,59 +0,0 @@
|
||||
pkgname=keepassgo-git
|
||||
pkgver=@PKGVER@
|
||||
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() {
|
||||
printf '%s\n' "@REPO_DIR@"
|
||||
}
|
||||
|
||||
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 git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$(_repo_dir)"
|
||||
|
||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||
install -Dm644 internal/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"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[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
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/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,15 +4,14 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -178,18 +177,6 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
|
||||
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 {
|
||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||
if err != nil {
|
||||
@@ -921,17 +908,6 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
|
||||
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) {
|
||||
model, config, err := loadLocalSource(path, key)
|
||||
if err == nil {
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
|
||||
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
updated := model
|
||||
updated.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(updated)
|
||||
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "Internet"},
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Mail",
|
||||
Username: "dannyocean",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://dispatch.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "eMail"},
|
||||
URL: "https://mail.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "eMail"},
|
||||
},
|
||||
},
|
||||
}, key.Password); err != nil {
|
||||
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
current.Entries[0].Path = []string{"Crew", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
|
||||
current.Entries[0].Path = []string{"Joe", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
|
||||
sess.Replace(current)
|
||||
|
||||
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)
|
||||
}
|
||||
rootGroups := db.Content.Root.Groups[0].Groups
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-1",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
||||
if len(got) != 1 || got[0].Title != "Git Server" {
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
{
|
||||
UUID: gokeepasslib.NewUUID(),
|
||||
Values: []gokeepasslib.ValueData{
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
|
||||
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: current.Entries[0].ID,
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: current.Entries[0].Path,
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
firstCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "remote-token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "updated remotely first",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
secondCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "local-token-2",
|
||||
URL: "https://vault.crew.example.invalid/security/badges",
|
||||
URL: "https://git.julianfamily.org/settings/tokens",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
second.Replace(secondCurrent)
|
||||
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
|
||||
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
|
||||
}
|
||||
if len(got[0].History) == 0 {
|
||||
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-other",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -953,63 +953,6 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1021,10 +964,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1033,10 +976,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-other",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1078,10 +1021,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1090,10 +1033,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-remote",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-remote",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
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.
|
||||
`
|
||||