Compare commits

..

93 Commits

Author SHA1 Message Date
Joe Julian 322dc84917 Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian 3d53ff8e95 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian 0a6b7fe5c0 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 82bfd694b6 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian d9c2d48ff5 Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian 8495beb8dc Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 3e471366ce Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian f9b58367da Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 20f84ac58e Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 2db8a71704 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b7a4742ee6 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 9afddd7a93 Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian 84c188129e Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 980d30f6c2 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e6b33134c9 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian a269217c60 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian 722d2eefa0 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian dba0bf1f2c Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 47942014d7 Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian ed2118223f Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian 9e30f654b2 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 1ecad1fae9 Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian fc34508689 Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 9dea7a4a34 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian e5904dd224 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian da769a9728 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian f47682f3a1 Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 9dcc5f1d6f Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian ee9b00bd85 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 8ce924e08f Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian bda28eef4b Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian bf56e38bc5 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian d137018d3a Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian fcc0291dd9 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian cc127a013e Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian b2f1d9a66d Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian b391cea295 Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian fba135ff09 Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 71b383114d Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian 75dc6453f7 Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 72b413a11a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 75002a4c47 Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian e98256709d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 5e15ad3265 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian d492743eb1 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian 4a629c16bd Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 62fc343ecf Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 1e51eff76e Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 4509634a15 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian 47bbbdb05e Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian 4fbaee3970 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian d2b49ca0ad Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian fedeab2fc1 Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian b56401b5c6 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 01559a3a2b Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian 16dc1de3c8 Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian bd9674a1f5 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian e2e38a97a0 Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian 34a6e0150e Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian ec063ac81f Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian ac5b6894cf Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian d6bc213474 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian 706a72e135 test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian f8111f626f test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian 0bfa30de91 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 24dcf9c8ad Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian ea5c8b3c31 Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian c355eebed5 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 7651b3b237 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian da942b41d7 Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 1c0cd20bcc Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian cffe05af82 Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian 8deccced9e Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4fe912b41f Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian 55ca6352b4 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian 0cfccb58d6 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian b043ecdc83 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian d3be07f252 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 0f30c5b1c5 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 985150c36f Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian f39bdcd3be Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 48ffa78fa2 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 1349fe6e38 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 19ccab1d8d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian ae1921506d Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian 1f51904567 Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian ef867adfa1 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian 5eb2068d3e Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian 77407e0510 Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian dd0b0b6f6e Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 566256ab79 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian a4b3d5c1e1 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian ce01c47256 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
77 changed files with 1564 additions and 16595 deletions
-116
View File
@@ -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 users 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 users 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
-173
View File
@@ -1,173 +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
# 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 -o "${out}" .
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
make apk
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"
-16
View File
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
## Skills ## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work. - Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are: - The available Go skills for this repository are:
`go-code-review`, `go-code-review`,
`go-concurrency`, `go-concurrency`,
@@ -128,20 +126,6 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing. - Keep `golangci-lint` passing.
- Keep `go test ./...` passing. - Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users 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 users 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 users real config. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
+1 -13
View File
@@ -9,7 +9,7 @@ ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35 ANDROID_TARGET_SDK ?= 35
.PHONY: apk .PHONY: apk
apk: android/keepassgo-android.jar apk:
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; } @test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -30,15 +30,3 @@ apk: android/keepassgo-android.jar
-targetsdk $(ANDROID_TARGET_SDK) \ -targetsdk $(ANDROID_TARGET_SDK) \
-icon assets/keepassgo-icon.png \ -icon assets/keepassgo-icon.png \
. .
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
-18
View File
@@ -41,24 +41,6 @@ Desktop build:
go build ./... go build ./...
``` ```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging ## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`. KeePassGO uses Gio, so Android packaging is done with `gogio`.
-111
View File
@@ -6,102 +6,6 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria. Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met. The product is not complete until the global exit criteria at the end of this file are also met.
## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish.
### Primary Workflow Changes
These should remain in the main user flow rather than being hidden behind a settings gear.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
keep recent vault selection visually obvious and clearly tappable.
- Local open flow:
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
- Local open flow:
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
- Local open flow:
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
- Remote open flow:
break the remote form into clearer sections such as `Location` and `Authentication`.
- Remote open flow:
make recent remote connections easier to scan with a friendlier label than raw URL and path.
- Locked screen:
show clear vault identity and target summary so the user knows what is being unlocked.
- Entries screen:
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
- Entries screen:
make breadcrumbs compress more aggressively on phone.
- Entries screen:
improve entry-row hierarchy and selected-state contrast.
- Entries screen:
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
- Group navigation:
make the distinction between root, current group, and child groups more obvious.
- Group navigation:
separate navigation controls from group-management controls more clearly.
- Entry detail:
tighten field spacing and reduce unnecessary whitespace.
- Entry detail:
group password reveal and copy actions more clearly.
- Entry detail:
make attachments more visible and actionable.
- Entry edit:
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
- Entry edit:
make add/remove affordances for custom fields more visually obvious.
- Entry edit:
make generated-password draft state more explicit before save.
- Recycle bin:
make it visually distinct from normal entry browsing.
- API tokens:
give token list, token detail, and policy editing a clearer dedicated management surface.
- API tokens:
make policy rows easier to scan by separating effect, operation, and resource visually.
- API audit:
improve empty-state guidance and provide quick filtering by token, decision, and operation.
- Synchronize:
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
fix Android local-open ANR behavior before deeper mobile polish.
- Autofill UX:
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
### Settings Gear Candidates
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
- Vault security:
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
- Vault security:
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
- Remote preferences:
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
- UI preferences:
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
- Autofill behavior:
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
- Sync defaults:
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
- Notification preferences:
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
- Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
- Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments ## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC. These segments define the work for programmatic access control over gRPC.
@@ -545,21 +449,6 @@ Exit criteria:
- Focus and accessibility states are visible and intentional. - Focus and accessibility states are visible and intentional.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 21: Accessibility Fill Generalization
Scope:
- Extend Android accessibility-based fill beyond the current Chrome demo path.
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
Exit criteria:
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
- `go test ./...` passes.
### Segment 17: UI Completion And Error Surfaces ### Segment 17: UI Completion And Error Surfaces
Scope: Scope:
-24
View File
@@ -1,24 +0,0 @@
<service
android:name="org.julianfamily.keepassgo.KeePassGOAutofillService"
android:exported="true"
android:label="KeePassGO Autofill"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data
android:name="android.autofill"
android:resource="@xml/keepassgo_autofill_service" />
</service>
<service
android:name="org.julianfamily.keepassgo.KeePassGOAccessibilityService"
android:exported="true"
android:label="KeePassGO Accessibility Fill"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
Binary file not shown.
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeViewFocused|typeViewTextChanged|typeWindowContentChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:settingsActivity="" />
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -1,334 +0,0 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillCacheStore() {
}
static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return null;
}
List<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
if (entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
private static File findCacheFile(Context context) {
List<File> candidates = new ArrayList<>();
File filesDir = context.getFilesDir();
if (filesDir != null) {
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
}
File baseDir = context.getDataDir();
if (baseDir != null) {
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
return candidate;
}
}
Log.i(TAG, "no autofill cache file in " + candidates);
return null;
}
private static List<Entry> readEntries(File cacheFile) throws IOException {
List<Entry> entries = new ArrayList<>();
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("entries".equals(name)) {
reader.beginArray();
while (reader.hasNext()) {
entries.add(readEntry(reader));
}
reader.endArray();
} else {
reader.skipValue();
}
}
reader.endObject();
}
return entries;
}
private static Entry readEntry(JsonReader reader) throws IOException {
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "title":
title = nextString(reader);
break;
case "username":
username = nextString(reader);
break;
case "password":
password = nextString(reader);
break;
case "url":
url = nextString(reader);
break;
case "host":
host = normalizeHost(nextString(reader));
break;
case "targets":
reader.beginArray();
while (reader.hasNext()) {
targets.add(nextString(reader));
}
reader.endArray();
break;
default:
reader.skipValue();
break;
}
}
reader.endObject();
return new Entry(title, username, password, host, url, targets);
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
static final class Entry {
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
Entry(String title, String username, String password, String host, String url, List<String> targets) {
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
}
}
}
@@ -1,195 +0,0 @@
package org.julianfamily.keepassgo;
import android.accessibilityservice.AccessibilityService;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
public final class KeePassGOAccessibilityService extends AccessibilityService {
private static final String TAG = "KeePassGOA11y";
private String lastFilledSignature = "";
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
try {
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null) {
return;
}
CharSequence packageName = root.getPackageName();
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
return;
}
ChromeForm form = inspectChrome(root);
if (form == null || form.passwordField == null) {
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url);
if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.url);
return;
}
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField);
if (signature.equals(lastFilledSignature)) {
return;
}
boolean filled = false;
if (form.usernameField != null) {
filled |= setNodeText(form.usernameField, entry.username);
}
filled |= setNodeText(form.passwordField, entry.password);
if (filled) {
lastFilledSignature = signature;
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username);
}
} catch (Exception err) {
Log.e(TAG, "accessibility fill failed", err);
}
}
@Override
public void onInterrupt() {
Log.i(TAG, "accessibility service interrupted");
}
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm();
walk(root, editables, form);
if (form.url.isEmpty()) {
return null;
}
if (form.passwordField == null) {
for (AccessibilityNodeInfo node : editables) {
if (isPasswordNode(node)) {
form.passwordField = node;
break;
}
}
}
if (form.usernameField == null && form.passwordField != null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
if (isUsernameNode(node)) {
form.usernameField = node;
break;
}
}
if (form.usernameField == null) {
for (AccessibilityNodeInfo node : editables) {
if (node.equals(form.passwordField)) {
continue;
}
form.usernameField = node;
break;
}
}
}
return form;
}
private static void walk(
AccessibilityNodeInfo node,
List<AccessibilityNodeInfo> editables,
ChromeForm form
) {
if (node == null) {
return;
}
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
CharSequence text = node.getText();
if (text != null) {
form.url = text.toString().trim();
}
}
if (node.isEditable()) {
editables.add(node);
if (form.passwordField == null && isPasswordNode(node)) {
form.passwordField = node;
} else if (form.usernameField == null && isUsernameNode(node)) {
form.usernameField = node;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
walk(child, editables, form);
}
}
}
private static boolean isPasswordNode(AccessibilityNodeInfo node) {
if (node.isPassword()) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AccessibilityNodeInfo node) {
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AccessibilityNodeInfo node, String term) {
String lowerTerm = term.toLowerCase();
return containsLower(node.getHintText(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)
|| containsLower(node.getViewIdResourceName(), lowerTerm)
|| containsLower(node.getPaneTitle(), lowerTerm);
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static boolean setNodeText(AccessibilityNodeInfo node, String value) {
if (node == null || !node.isEditable() || value == null) {
return false;
}
Bundle args = new Bundle();
args.putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
value
);
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
}
private static String nodeKey(AccessibilityNodeInfo node) {
CharSequence viewID = node.getViewIdResourceName();
if (viewID != null) {
return viewID.toString();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return String.valueOf(node.getUniqueId());
}
CharSequence hint = node.getHintText();
if (hint != null) {
return hint.toString();
}
return String.valueOf(node.hashCode());
}
private static final class ChromeForm {
String url = "";
AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField;
}
}
@@ -1,215 +0,0 @@
package org.julianfamily.keepassgo;
import android.app.assist.AssistStructure;
import android.os.CancellationSignal;
import android.service.autofill.AutofillService;
import android.service.autofill.Dataset;
import android.service.autofill.FillCallback;
import android.service.autofill.FillContext;
import android.service.autofill.FillRequest;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveCallback;
import android.service.autofill.SaveRequest;
import android.text.InputType;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import java.util.List;
public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill";
private static final String APP_SCHEME = "androidapp://";
@Override
public void onConnected() {
super.onConnected();
Log.i(TAG, "service connected");
}
@Override
public void onDisconnected() {
Log.i(TAG, "service disconnected");
super.onDisconnected();
}
@Override
public void onFillRequest(
FillRequest request,
CancellationSignal cancellationSignal,
FillCallback callback
) {
try {
Log.i(TAG, "fill request flags=" + request.getFlags());
List<FillContext> contexts = request.getFillContexts();
if (contexts.isEmpty()) {
Log.i(TAG, "fill request had no contexts");
callback.onSuccess(null);
return;
}
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields();
ParsedTarget target = parseWindow(structure, fields);
Log.i(
TAG,
"parsed target=" + target.matchTarget
+ " package=" + target.packageName
+ " usernameId=" + fields.usernameId
+ " passwordId=" + fields.passwordId
);
if (fields.passwordId == null) {
Log.i(TAG, "no password field found");
callback.onSuccess(null);
return;
}
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
if (entry == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
Log.i(TAG, "returning dataset");
callback.onSuccess(response);
} catch (Exception err) {
Log.e(TAG, "fill request failed", err);
callback.onFailure(err.getMessage());
}
}
@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
Log.i(TAG, "save request");
callback.onSuccess();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = "";
final int windowCount = structure.getWindowNodeCount();
for (int i = 0; i < windowCount; i++) {
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
String next = parseNode(root, fields);
if (!next.isEmpty()) {
domain = next;
}
}
String packageName = "";
if (structure.getActivityComponent() != null) {
packageName = structure.getActivityComponent().getPackageName();
}
if (!domain.isEmpty()) {
return new ParsedTarget(domain, packageName);
}
if (packageName != null && !packageName.isEmpty()) {
return new ParsedTarget(APP_SCHEME + packageName, packageName);
}
return new ParsedTarget("", "");
}
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
String domain = "";
if (node.getWebDomain() != null) {
domain = node.getWebDomain();
}
AutofillId id = node.getAutofillId();
if (id != null) {
if (fields.passwordId == null && isPasswordNode(node)) {
fields.passwordId = id;
} else if (fields.usernameId == null && isUsernameNode(node)) {
fields.usernameId = id;
}
}
for (int i = 0; i < node.getChildCount(); i++) {
String childDomain = parseNode(node.getChildAt(i), fields);
if (!childDomain.isEmpty()) {
domain = childDomain;
}
}
return domain;
}
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
int inputType = node.getInputType();
int variation = inputType & InputType.TYPE_MASK_VARIATION;
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
return true;
}
return nodeMatches(node, "password");
}
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
int autofillType = node.getAutofillType();
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
return false;
}
if (isPasswordNode(node)) {
return false;
}
return nodeMatches(node, "username")
|| nodeMatches(node, "email")
|| nodeMatches(node, "login")
|| nodeMatches(node, "user");
}
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
String lowerTerm = term.toLowerCase();
String[] hints = node.getAutofillHints();
if (hints != null) {
for (String hint : hints) {
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
return true;
}
}
}
if (containsLower(node.getHint(), lowerTerm)
|| containsLower(node.getIdEntry(), lowerTerm)
|| containsLower(node.getText(), lowerTerm)
|| containsLower(node.getContentDescription(), lowerTerm)) {
return true;
}
return false;
}
private static boolean containsLower(CharSequence value, String term) {
return value != null && value.toString().toLowerCase().contains(term);
}
private static final class ParsedFields {
AutofillId usernameId;
AutofillId passwordId;
}
private static final class ParsedTarget {
final String matchTarget;
final String packageName;
ParsedTarget(String matchTarget, String packageName) {
this.matchTarget = matchTarget;
this.packageName = packageName;
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{ if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t), testAPITokenEntry(t),
{ID: "entry-1", Title: "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 { }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err) t.Fatalf("Create() error = %v", err)
+69 -69
View File
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
token.Entry([]string{"Root", "API Tokens"}), token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "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() 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 { if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
} }
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil { if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" { if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries) t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
} }
} }
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, {ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, {ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, {ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com", BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx", Path: "vaults/main.kdbx",
Username: "rustyryan", Username: "jjulian",
Password: "dav-token", Password: "dav-token",
MasterPassword: "correct horse battery staple", MasterPassword: "correct horse battery staple",
}); err != nil { }); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
} }
if resp.Entries[0].Title != "Vault Console" { if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console") 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" { if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "vault-console", Id: "git-server",
Target: "password", Target: "password",
}); err != nil { }); err != nil {
t.Fatalf("CopyEntryField() error = %v", err) t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{ Entry: &keepassgov1.Entry{
Id: "surveillance-console", Id: "ha-codex",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
Url: "https://surveillance.crew.example.invalid", Url: "https://lights.julianfamily.org",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "lights-admin", "X-Role": "lights-admin",
}, },
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if upserted.Entry.Title != "Surveillance Console" { if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console") t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
} }
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil { if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
} }
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"}) restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
if err != nil { if err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
if restored.Entry.Title != "Vault Console" { if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console") t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
} }
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" { if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries) 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{ instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login", TemplateId: "website-login",
Overrides: &keepassgov1.Entry{ Overrides: &keepassgov1.Entry{
Id: "bellagio", Id: "dynadot",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "hunter2", Password: "hunter2",
Url: "https://bellagio.example.invalid", Url: "https://www.dynadot.com",
Fields: map[string]string{ Fields: map[string]string{
"Environment": "staging", "Environment": "staging",
}, },
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err) t.Fatalf("InstantiateTemplate() error = %v", err)
} }
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." { if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry) t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
} }
if got := instantiated.Entry.Fields["Environment"]; got != "staging" { if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"}) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
if err != nil { if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err) t.Fatalf("ListEntryHistory() error = %v", err)
} }
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
} }
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "vault-console", Id: "git-server",
HistoryIndex: 0, HistoryIndex: 0,
}) })
if err != nil { if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content") uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "vault-console", EntryId: "git-server",
Name: "token.txt", Name: "token.txt",
Content: uploaded, Content: uploaded,
}); err != nil { }); err != nil {
t.Fatalf("UploadAttachment() error = %v", err) t.Fatalf("UploadAttachment() error = %v", err)
} }
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "vault-console", EntryId: "git-server",
Name: "token.txt", Name: "token.txt",
}) })
if err != nil { if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "vault-console", EntryId: "git-server",
Name: "token.txt", Name: "token.txt",
}); err != nil { }); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err) t.Fatalf("DeleteAttachment() error = %v", err)
} }
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "automation", "X-Role": "automation",
}, },
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "vault-console-h1", ID: "git-server-h1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-0", Password: "token-0",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
{ {
ID: "surveillance-console", ID: "ha-codex",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
@@ -1090,9 +1090,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, 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: "vault-console", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
), ),
}, },
Templates: []vault.Entry{ Templates: []vault.Entry{
-3
View File
@@ -16,9 +16,6 @@ const (
EventApprovalDenied EventType = "approval_denied" EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled" EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out" EventApprovalTimedOut EventType = "approval_timed_out"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected" EventAuthRejected EventType = "auth_rejected"
) )
-19
View File
@@ -47,22 +47,3 @@ func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0]) t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
} }
} }
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
+1 -35
View File
@@ -61,7 +61,6 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface { type AdvancedSynchronizableSession interface {
CurrentSession CurrentSession
SynchronizeFromLocal(string) error SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error
@@ -303,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil return model.EntriesInPath(s.CurrentPath), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -723,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil return nil
} }
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error { func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession) session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok { if !ok {
@@ -850,27 +837,6 @@ func (s *State) CreateGroup(name string) error {
return nil return nil
} }
func (s *State) MoveCurrentGroup(parent []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
current := append([]string(nil), s.CurrentPath...)
if err := model.MoveGroup(current, parent); err != nil {
return err
}
session.Replace(model)
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error { func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
+82 -220
View File
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, {ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, {ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Joe", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -39,39 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) { if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles) t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
}
}
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
} }
} }
@@ -171,14 +140,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, {ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "surveillance", SearchQuery: "lights",
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -186,46 +155,11 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Title != "Surveillance Console" { if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
} }
} }
func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
SearchQuery: "missing",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) { func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -349,7 +283,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, {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"}}, {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
}, },
}, },
@@ -418,13 +352,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, {ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
}, },
}, },
}, },
CurrentPath: []string{"Crew"}, CurrentPath: []string{"Joe"},
} }
got, err := state.ChildGroups() got, err := state.ChildGroups()
@@ -470,19 +404,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, {ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "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 { if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err) t.Fatalf("SelectVisibleIndex() error = %v", err)
} }
if got := state.SelectedEntryID; got != "vault-console" { if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
} }
if err := state.ToggleVisibleIndex(1); err != nil { if err := state.ToggleVisibleIndex(1); err != nil {
@@ -524,14 +458,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ 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{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
if err := state.DeleteSelectedEntry(); err != nil { if err := state.DeleteSelectedEntry(); err != nil {
@@ -546,8 +480,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
} }
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" { if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin) t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
} }
if !state.Dirty { if !state.Dirty {
@@ -561,7 +495,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, {ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
@@ -570,7 +504,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, 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) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -579,8 +513,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "vault-console" { if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got) t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -600,19 +534,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
} }
entry := vault.Entry{ entry := vault.Entry{
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
} }
if err := state.UpsertEntry(entry); err != nil { if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if got := state.SelectedEntryID; got != "vault-console" { if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -620,7 +554,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got) t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -652,11 +586,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
} }
entry, err := state.InstantiateTemplate("website-login", vault.Entry{ entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "bellagio", ID: "dynadot",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "hunter2", Password: "hunter2",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
if err != nil { if err != nil {
@@ -667,16 +601,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
} }
if got := state.SelectedEntryID; got != "bellagio" { if got := state.SelectedEntryID; got != "dynadot" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio") t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "bellagio" { if len(got) != 1 || got[0].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got) t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -743,24 +677,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ 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{ state := State{
Session: sess, 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 { if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err) t.Fatalf("DuplicateSelectedEntry() error = %v", err)
} }
if duplicate.ID != "vault-console-copy" { if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
} }
if state.SelectedEntryID != "vault-console-copy" { if state.SelectedEntryID != "git-server-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID) t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
} }
if len(sess.model.Entries) != 2 { if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -772,13 +706,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ 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{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -791,8 +725,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
} }
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "vault-console" { if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath) t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
} }
if !state.Dirty { if !state.Dirty {
@@ -806,19 +740,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Password: "new-token", Password: "new-token",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []vault.Entry{ 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{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
if err := state.RestoreSelectedEntryVersion(0); err != nil { if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -862,7 +796,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
Dirty: true, Dirty: true,
} }
@@ -891,7 +825,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
Dirty: true, Dirty: true,
} }
@@ -942,7 +876,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
Dirty: true, Dirty: true,
} }
@@ -970,14 +904,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ 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{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
if err := state.Lock(); err != nil { if err := state.Lock(); err != nil {
@@ -1000,7 +934,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ 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, locked: true,
@@ -1018,8 +952,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "vault-console" { if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got) t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
} }
} }
@@ -1048,7 +982,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{ state := State{
Section: SectionEntries, Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
SearchQuery: "git", SearchQuery: "git",
} }
@@ -1084,7 +1018,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel() t.Parallel()
state := State{ state := State{
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
ErrorMessage: "previous error", ErrorMessage: "previous error",
} }
@@ -1128,7 +1062,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root"}, CurrentPath: []string{"Root"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
state.EnterGroup("Internet") state.EnterGroup("Internet")
@@ -1146,7 +1080,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console", SelectedEntryID: "git-server",
} }
state.NavigateToPath([]string{"Root", "Home Assistant"}) state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1216,27 +1150,6 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
} }
} }
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{
Session: session,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Infrastructure / Prod"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
}
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1297,7 +1210,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1313,45 +1226,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
} }
if got[0].ID != "bellagio" && got[1].ID != "bellagio" { if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got) t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry") t.Fatal("Dirty = false, want true after MoveSelectedEntry")
} }
} }
func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
session := &mutableStubSession{model: model}
state := State{
Session: session,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
}
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1359,7 +1241,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1387,7 +1269,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1413,7 +1295,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1439,7 +1321,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1457,7 +1339,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1483,7 +1365,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio", SelectedEntryID: "dynadot",
} }
err := state.DeleteAttachmentFromSelectedEntry("token.txt") err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1511,8 +1393,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model { func testVaultModel() vault.Model {
return vault.Model{ return vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}}, {ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}}, {ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
}, },
} }
} }
@@ -1584,26 +1466,6 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
return nil return nil
} }
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key s.changedKey = key
return nil return nil
-296
View File
@@ -1,296 +0,0 @@
package autofillcache
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.julianfamily.org/keepassgo/vault"
)
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
Host string `json:"host"`
Targets []string `json:"targets,omitempty"`
Path []string `json:"path,omitempty"`
}
type File struct {
UpdatedAt string `json:"updatedAt"`
Entries []Entry `json:"entries"`
}
type MatchStatus string
const (
MatchStatusNone MatchStatus = ""
MatchStatusFound MatchStatus = "found"
MatchStatusAmbiguous MatchStatus = "ambiguous"
MatchStatusMissing MatchStatus = "missing"
)
type MatchResult struct {
Status MatchStatus `json:"status"`
Entry Entry `json:"entry,omitempty"`
}
func Match(cache File, webURL string) (Entry, bool) {
result := Resolve(cache, webURL)
return result.Entry, result.Status == MatchStatusFound
}
func Resolve(cache File, webURL string) MatchResult {
target := normalizeURL(webURL)
if target.host == "" {
return MatchResult{Status: MatchStatusMissing}
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entryMatchesHost(entry, target.host) {
exactHost = append(exactHost, entry)
continue
}
if entryMatchesParentHost(entry, target.host) {
parentHost = append(parentHost, entry)
}
}
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
return result
}
return chooseEntry(target, parentHost)
}
func Build(model vault.Model, now time.Time) File {
entries := make([]Entry, 0, len(model.Entries))
for _, item := range model.Entries {
targets := collectTargets(item)
host := normalizeHost(item.URL)
if host == "" {
for _, target := range targets {
host = normalizeHost(target)
if host != "" {
break
}
}
}
if host == "" {
continue
}
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
continue
}
entries = append(entries, Entry{
ID: item.ID,
Title: item.Title,
Username: item.Username,
Password: item.Password,
URL: item.URL,
Host: host,
Targets: targets,
Path: append([]string(nil), item.Path...),
})
}
return File{
UpdatedAt: now.UTC().Format(time.RFC3339),
Entries: entries,
}
}
func Write(path string, model vault.Model, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(Build(model, now), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func Clear(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeHost(raw string) string {
return normalizeURL(raw).host
}
type normalizedTarget struct {
host string
path string
url string
}
func normalizeURL(raw string) normalizedTarget {
value := strings.TrimSpace(raw)
if value == "" {
return normalizedTarget{}
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return normalizedTarget{}
}
host := strings.TrimSpace(parsed.Hostname())
path := cleanPath(parsed.EscapedPath())
return normalizedTarget{
host: strings.ToLower(host),
path: path,
url: strings.ToLower(host) + path,
}
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
path = strings.TrimRight(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
switch len(entries) {
case 0:
return MatchResult{Status: MatchStatusMissing}
case 1:
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
}
exact := make([]Entry, 0)
bestPrefixLen := -1
bestPrefix := make([]Entry, 0)
for _, entry := range entries {
exactMatch, prefixLen := bestTargetMatch(entry, target)
if exactMatch {
exact = append(exact, entry)
continue
}
if prefixLen <= 0 {
continue
}
switch {
case prefixLen > bestPrefixLen:
bestPrefixLen = prefixLen
bestPrefix = []Entry{entry}
case prefixLen == bestPrefixLen:
bestPrefix = append(bestPrefix, entry)
}
}
if len(exact) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
}
if len(exact) > 1 {
return MatchResult{Status: MatchStatusAmbiguous}
}
if len(bestPrefix) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
}
if len(bestPrefix) == 0 {
return MatchResult{Status: MatchStatusMissing}
}
return MatchResult{Status: MatchStatusAmbiguous}
}
func collectTargets(item vault.Entry) []string {
seen := make(map[string]struct{})
targets := make([]string, 0, 1+len(item.Fields))
appendTarget := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
targets = append(targets, value)
}
appendTarget(item.URL)
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
upper := strings.ToUpper(strings.TrimSpace(key))
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
appendTarget(item.Fields[key])
}
}
return targets
}
func entryTargets(entry Entry) []normalizedTarget {
values := entry.Targets
if len(values) == 0 {
values = []string{entry.URL}
}
targets := make([]normalizedTarget, 0, len(values))
for _, value := range values {
target := normalizeURL(value)
if target.host == "" {
continue
}
targets = append(targets, target)
}
return targets
}
func entryMatchesHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host == host {
return true
}
}
return false
}
func entryMatchesParentHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
return true
}
}
return false
}
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
bestPrefixLen := -1
for _, candidate := range entryTargets(entry) {
if candidate.url == target.url {
return true, 0
}
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
bestPrefixLen = pathLen
}
}
}
return false, bestPrefixLen
}
-339
View File
@@ -1,339 +0,0 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.julianfamily.org/keepassgo/vault"
)
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
got := Build(vault.Model{
Entries: []vault.Entry{
{
ID: "one",
Title: "Chrome Test",
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"},
},
{
ID: "two",
Title: "No Password",
Username: "joe",
URL: "https://example.com",
},
{
ID: "three",
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
},
},
},
}, now)
if len(got.Entries) != 2 {
t.Fatalf("entry count = %d, want 2", len(got.Entries))
}
if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
}
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
}
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
t.Fatalf("updatedAt = %q", got.UpdatedAt)
}
}
func TestWriteAndClear(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "autofill-cache.json")
model := vault.Model{
Entries: []vault.Entry{
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
},
}
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var got File
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("cache entries = %#v", got.Entries)
}
if err := Clear(path); err != nil {
t.Fatalf("Clear() error = %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cache path still exists, stat err = %v", err)
}
}
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Primary Login",
Username: "first",
Password: "secret1",
URL: "https://10.0.2.2:8443/login/",
Host: "10.0.2.2",
},
{
ID: "two",
Title: "Alt Login",
Username: "second",
Password: "secret2",
URL: "https://10.0.2.2:8443/alt/",
Host: "10.0.2.2",
},
},
}
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Host A",
Username: "first",
Password: "secret1",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
},
}
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
}
}
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Admin Login",
Username: "admin",
Password: "secret1",
URL: "https://example.com/admin",
Host: "example.com",
},
{
ID: "two",
Title: "Shared Login A",
Username: "shared-a",
Password: "secret2",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
{
ID: "three",
Title: "Shared Login B",
Username: "shared-b",
Password: "secret3",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
},
}
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
}
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
}
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
t.Fatalf("Resolve(missing) = %#v, want missing", got)
}
}
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Generic Login",
Username: "generic",
Password: "secret1",
URL: "https://example.com/",
Host: "example.com",
},
{
ID: "two",
Title: "Admin Login",
Username: "admin",
Password: "secret2",
URL: "https://example.com/admin",
Host: "example.com",
},
},
}
got, ok := Match(cache, "https://example.com/admin/login")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird/login",
Host: "org.mozilla.thunderbird",
},
},
}
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird Primary",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
{
ID: "two",
Title: "Thunderbird Secondary",
Username: "other-user",
Password: "secret2",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
},
}
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
}
}
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://account.blinknetwork.com",
Host: "account.blinknetwork.com",
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
},
},
}
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://blinknetwork.com",
Host: "blinknetwork.com",
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
},
},
}
got, ok := Match(cache, "https://account.blinknetwork.com")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
}, },
}, },
} }
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target target Target
want string want string
}{ }{
{name: "username", target: TargetUsername, want: "dannyocean"}, {name: "username", target: TargetUsername, want: "joejulian"},
{name: "password", target: TargetPassword, want: "token-1"}, {name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"}, {name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter var writer memoryWriter
service := Service{Writer: &writer} service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil { if err := service.Copy(model, "git-server", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err) t.Fatalf("Copy() error = %v", err)
} }
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
} }
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}}, Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
} }
err = service.Copy(model, "vault-console", Target("unsupported")) err = service.Copy(model, "git-server", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) { if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
} }
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}} service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "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) { if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err) t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
} }
-63
View File
@@ -1,63 +0,0 @@
package main
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/clipboard"
)
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
-42
View File
@@ -1,42 +0,0 @@
package main
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation - list navigation
- search focus - search focus
- new-entry focus transitions - new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/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 ## Current screen-reader boundary
+1 -6
View File
@@ -2,11 +2,8 @@ module git.julianfamily.org/keepassgo
go 1.26 go 1.26
replace gioui.org/cmd => ./third_party/gioui-cmd
require ( require (
gioui.org v0.8.0 gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -19,7 +16,6 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/Antonboom/errname v1.0.0 // indirect
@@ -76,7 +72,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -195,7 +190,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
+4 -8
View File
@@ -39,13 +39,11 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= 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/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -228,8 +226,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/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -675,8 +671,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 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 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+498 -3073
View File
File diff suppressed because it is too large Load Diff
+238 -2527
View File
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
pkgbase = keepassgo-git
pkgdesc = KeePass-compatible password manager written in Go
pkgver = r160.5fa79bd
pkgrel = 1
url = https://git.julianfamily.org/joejulian/keepassgo
arch = x86_64
arch = aarch64
license = custom
makedepends = git
makedepends = go
makedepends = pkgconf
depends = glibc
depends = hicolor-icon-theme
depends = libx11
depends = libxcursor
depends = libxfixes
depends = libxkbcommon
depends = libxkbcommon-x11
depends = mesa
depends = wayland
provides = keepassgo
conflicts = keepassgo
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
sha256sums = SKIP
pkgname = keepassgo-git
@@ -1,63 +0,0 @@
pkgname=keepassgo-git
pkgver=r0.0000000
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
printf '%s\n' "${srcdir}/keepassgo"
return
fi
cd "${startdir}/../../.." || exit 1
pwd
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
go build -o keepassgo .
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -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
-95
View File
@@ -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())
+40 -151
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -33,33 +32,6 @@ type Manager struct {
remoteVersion webdav.Version remoteVersion webdav.Version
} }
type PreparedLocalOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
}
type PreparedRemoteOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Client webdav.Client
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
}
type PreparedUnlock struct {
Model vault.Model
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
return vault.DetectSecuritySettings(m.config) return vault.DetectSecuritySettings(m.config)
} }
@@ -93,10 +65,6 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != "" return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
} }
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
func (m *Manager) IsLocked() bool { func (m *Manager) IsLocked() bool {
return m.locked return m.locked
} }
@@ -106,11 +74,23 @@ func (m *Manager) IsRemote() bool {
} }
func (m *Manager) Open(path string, key vault.MasterKey) error { func (m *Manager) Open(path string, key vault.MasterKey) error {
prepared, err := PrepareLocalOpen(path, key) content, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return fmt.Errorf("read %s: %w", path, err)
} }
m.ApplyPreparedLocalOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
m.model = model
m.config = config
m.path = path
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
return nil return nil
} }
@@ -127,11 +107,25 @@ func (m *Manager) Save() error {
} }
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error { func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
prepared, err := PrepareRemoteOpen(client, path, key) content, version, err := client.Open(path)
if err != nil { if err != nil {
return err return fmt.Errorf("open remote %s: %w", path, err)
} }
m.ApplyPreparedRemoteOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("decode remote %s: %w", path, err)
}
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.remoteClient = &client
m.remotePath = path
m.remoteVersion = version
return nil return nil
} }
@@ -178,18 +172,6 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged) return m.persistMergedToCurrentSource(merged)
} }
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error { func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key) other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil { if err != nil {
@@ -283,101 +265,19 @@ func (m *Manager) Lock() error {
} }
func (m *Manager) Unlock(key vault.MasterKey) error { func (m *Manager) Unlock(key vault.MasterKey) error {
prepared, err := PrepareUnlock(m.encoded, key) model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
if err != nil { if err != nil {
return err return fmt.Errorf("unlock vault: %w", err)
} }
m.ApplyPreparedUnlock(prepared)
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.locked = false
return nil return nil
} }
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
content, err := os.ReadFile(path)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
content, version, err := client.Open(path)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
RemoteVersion: version,
}, nil
}
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
if err != nil {
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.path = prepared.Path
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = &prepared.Client
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
var ( var (
model vault.Model model vault.Model
@@ -921,17 +821,6 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil return model, config, nil
} }
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key) model, config, err := loadLocalSource(path, key)
if err == nil { if err == nil {
+83 -140
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got) t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
} }
} }
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model updated := model
updated.UpsertEntry(vault.Entry{ updated.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(updated) sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Crew", "Internet"}, Path: []string{"keepass", "Joe", "Internet"},
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Mail", Title: "Mail",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://dispatch.crew.example.invalid", URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Crew", "eMail"}, Path: []string{"keepass", "Joe", "eMail"},
}, },
}, },
}, key.Password); err != nil { }, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
current.Entries[0].Path = []string{"Crew", "Internet"} current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"}) current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
sess.Replace(current) sess.Replace(current)
if err := sess.Save(); err != nil { if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups) t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
} }
rootGroups := db.Content.Root.Groups[0].Groups rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" { if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups) t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
} }
} }
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want 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{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-1", Password: "token-1",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" { if len(got) != 1 || got[0].Title != "Git Server" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
} }
var reopened Manager var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{ {
UUID: gokeepasslib.NewUUID(), UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{ Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}}, {Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}}, {Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}}, {Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://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{ current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID, ID: current.Entries[0].ID,
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: current.Entries[0].Path, Path: current.Entries[0].Path,
}) })
sess.Replace(current) sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
firstCurrent.UpsertEntry(vault.Entry{ firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "remote-token-2", Password: "remote-token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "updated remotely first", Notes: "updated remotely first",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
secondCurrent.UpsertEntry(vault.Entry{ secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "local-token-2", Password: "local-token-2",
URL: "https://vault.crew.example.invalid/security/badges", URL: "https://git.julianfamily.org/settings/tokens",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
second.Replace(secondCurrent) second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
if got[0].Password != "local-token-2" || got[0].URL != "https://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]) t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
} }
if len(got[0].History) == 0 { if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-current", Password: "token-current",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "token-other", Password: "token-other",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"}, 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) { func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1021,10 +964,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-current", Password: "token-current",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1033,10 +976,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "token-other", Password: "token-other",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1078,10 +1021,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-current", Password: "token-current",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1090,10 +1033,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-remote", ID: "entry-remote",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "token-remote", Password: "token-remote",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
-67
View File
@@ -1,67 +0,0 @@
# 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
-22
View File
@@ -1,22 +0,0 @@
# 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 ./...
-91
View File
@@ -1,91 +0,0 @@
# 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
-18
View File
@@ -1,18 +0,0 @@
# 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 ./...
-63
View File
@@ -1,63 +0,0 @@
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/>
---
-21
View File
@@ -1,21 +0,0 @@
# Gio Tools
Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
[![builds.sr.ht status](https://builds.sr.ht/~eliasnaur/gio-cmd.svg)](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.
-28
View File
@@ -1,28 +0,0 @@
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
)
-44
View File
@@ -1,44 +0,0 @@
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=
-143
View File
@@ -1,143 +0,0 @@
// 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()
}
File diff suppressed because it is too large Load Diff
-206
View File
@@ -1,206 +0,0 @@
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)
}
-32
View File
@@ -1,32 +0,0 @@
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)
}
}
}
-10
View File
@@ -1,10 +0,0 @@
// 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
-337
View File
@@ -1,337 +0,0 @@
// 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)
}
}
-83
View File
@@ -1,83 +0,0 @@
// 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.
`
-371
View File
@@ -1,371 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
//go:build linux
// +build linux
// This program demonstrates the use of a custom OpenGL ES context with
// app.Window.
package main
import (
"errors"
"fmt"
"image"
"image/color"
"log"
"os"
"runtime"
"strings"
"unsafe"
"gioui.org/app"
"gioui.org/gpu"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
/*
#cgo linux pkg-config: egl wayland-egl
#cgo freebsd openbsd CFLAGS: -I/usr/local/include
#cgo openbsd CFLAGS: -I/usr/X11R6/include
#cgo freebsd LDFLAGS: -L/usr/local/lib
#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
#cgo freebsd openbsd LDFLAGS: -lwayland-egl
#cgo CFLAGS: -DEGL_NO_X11
#cgo LDFLAGS: -lEGL -lGLESv2
#include <EGL/egl.h>
#include <wayland-client.h>
#include <wayland-egl.h>
#include <GLES3/gl3.h>
#define EGL_EGLEXT_PROTOTYPES
#include <EGL/eglext.h>
*/
import "C"
func getDisplay(ve app.ViewEvent) C.EGLDisplay {
switch ve := ve.(type) {
case app.X11ViewEvent:
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
case app.WaylandViewEvent:
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
}
panic("no display available")
}
func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
switch e := e.(type) {
case app.X11ViewEvent:
return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
case app.WaylandViewEvent:
eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
C.wl_egl_window_destroy(eglWin)
}
}
panic("no native view available")
}
type (
C = layout.Context
D = layout.Dimensions
)
type notifyFrame int
const (
notifyNone notifyFrame = iota
notifyInvalidate
notifyPrint
)
// notify keeps track of whether we want to print to stdout to notify the user
// when a frame is ready. Initially we want to notify about the first frame.
var notify = notifyInvalidate
type eglContext struct {
disp C.EGLDisplay
ctx C.EGLContext
surf C.EGLSurface
cleanup func()
}
func main() {
go func() {
// Set CustomRenderer so we can provide our own rendering context.
w := new(app.Window)
w.Option(app.CustomRenderer(true))
if err := loop(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func loop(w *app.Window) error {
var ops op.Ops
var (
ctx *eglContext
gioCtx gpu.GPU
ve app.ViewEvent
init bool
size image.Point
)
recreateContext := func() {
w.Run(func() {
if gioCtx != nil {
gioCtx.Release()
gioCtx = nil
}
if ctx != nil {
C.eglMakeCurrent(ctx.disp, nil, nil, nil)
ctx.Release()
ctx = nil
}
c, err := createContext(ve, size)
if err != nil {
log.Fatal(err)
}
ctx = c
})
if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
log.Fatal(err)
}
glGetString := func(e C.GLenum) string {
return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
}
fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
var err error
gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
if err != nil {
log.Fatal(err)
}
}
topLeft := quarterWidget{
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
}
topRight := quarterWidget{
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
}
botLeft := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
}
botRight := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
}
// eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
runtime.LockOSThread()
for {
switch e := w.Event().(type) {
case app.ViewEvent:
ve = e
init = true
if size != (image.Point{}) {
recreateContext()
}
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
if init && size != e.Size {
size = e.Size
recreateContext()
}
if gioCtx == nil || !init {
break
}
// Build ops.
gtx := app.NewContext(&ops, e)
// Clear background to white, even on embedded platforms such as webassembly.
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r1c1
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
// r1c2
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r2c1
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
// r2c2
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
)
}),
)
gtx.Execute(op.InvalidateCmd{})
log.Println("frame")
// Trigger window resize detection in ANGLE.
C.eglWaitClient()
// Draw custom OpenGL content.
drawGL()
// Render drawing ops.
if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
log.Fatal(fmt.Errorf("render failed: %v", err))
}
// Process non-drawing ops.
e.Frame(gtx.Ops)
switch notify {
case notifyInvalidate:
notify = notifyPrint
w.Invalidate()
case notifyPrint:
notify = notifyNone
fmt.Println("gio frame ready")
}
if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
}
}
}
return nil
}
func drawGL() {
C.glClearColor(0, 0, 0, 1)
C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
}
func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
view, cleanup := nativeViewFor(ve, size)
var nilv C.EGLNativeWindowType
if view == nilv {
return nil, fmt.Errorf("failed creating native view")
}
disp := getDisplay(ve)
if disp == 0 {
return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
}
var major, minor C.EGLint
if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
}
exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
attribs := []C.EGLint{
C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
C.EGL_BLUE_SIZE, 8,
C.EGL_GREEN_SIZE, 8,
C.EGL_RED_SIZE, 8,
C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
}
if srgb {
// Some drivers need alpha for sRGB framebuffers to work.
attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
}
attribs = append(attribs, C.EGL_NONE)
var (
cfg C.EGLConfig
numCfgs C.EGLint
)
if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
}
if numCfgs == 0 {
supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
if !supportsNoCfg {
return nil, errors.New("eglChooseConfig returned no configs")
}
}
ctxAttribs := []C.EGLint{
C.EGL_CONTEXT_CLIENT_VERSION, 3,
C.EGL_NONE,
}
ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
if ctx == nil {
return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
}
var surfAttribs []C.EGLint
if srgb {
surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
}
surfAttribs = append(surfAttribs, C.EGL_NONE)
surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
if surf == nil {
return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
}
return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
}
func (c *eglContext) Release() {
if c.ctx != nil {
C.eglDestroyContext(c.disp, c.ctx)
}
if c.surf != nil {
C.eglDestroySurface(c.disp, c.surf)
}
if c.cleanup != nil {
c.cleanup()
}
*c = eglContext{}
}
func hasExtension(exts []string, ext string) bool {
for _, e := range exts {
if ext == e {
return true
}
}
return false
}
// quarterWidget paints a quarter of the screen with one color. When clicked, it
// turns red, going back to its normal color when clicked again.
type quarterWidget struct {
color color.NRGBA
clicked bool
}
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
var color color.NRGBA
if w.clicked {
color = red
} else {
color = w.color
}
r := image.Rectangle{Max: gtx.Constraints.Max}
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
defer clip.Rect(image.Rectangle{
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, w)
for {
e, ok := gtx.Event(pointer.Filter{
Target: w,
Kinds: pointer.Press,
})
if !ok {
break
}
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
w.clicked = !w.clicked
// notify when we're done updating the frame.
notify = notifyInvalidate
}
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
-147
View File
@@ -1,147 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
// A simple app used for gogio's end-to-end tests.
package main
import (
"fmt"
"image"
"image/color"
"log"
"gioui.org/app"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
func main() {
go func() {
w := new(app.Window)
if err := loop(w); err != nil {
log.Fatal(err)
}
}()
app.Main()
}
type notifyFrame int
const (
notifyNone notifyFrame = iota
notifyInvalidate
notifyPrint
)
// notify keeps track of whether we want to print to stdout to notify the user
// when a frame is ready. Initially we want to notify about the first frame.
var notify = notifyInvalidate
type (
C = layout.Context
D = layout.Dimensions
)
func loop(w *app.Window) error {
topLeft := quarterWidget{
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
}
topRight := quarterWidget{
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
}
botLeft := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
}
botRight := quarterWidget{
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
}
var ops op.Ops
for {
e := w.Event()
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
// Clear background to white, even on embedded platforms such as webassembly.
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r1c1
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
// r1c2
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// r2c1
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
// r2c2
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
)
}),
)
e.Frame(gtx.Ops)
switch notify {
case notifyInvalidate:
notify = notifyPrint
w.Invalidate()
case notifyPrint:
notify = notifyNone
fmt.Println("gio frame ready")
}
}
}
}
// quarterWidget paints a quarter of the screen with one color. When clicked, it
// turns red, going back to its normal color when clicked again.
type quarterWidget struct {
color color.NRGBA
clicked bool
}
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
var color color.NRGBA
if w.clicked {
color = red
} else {
color = w.color
}
r := image.Rectangle{Max: gtx.Constraints.Max}
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
defer clip.Rect(image.Rectangle{
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, w)
filter := pointer.Filter{
Target: w,
Kinds: pointer.Press,
}
for {
e, ok := gtx.Event(filter)
if !ok {
break
}
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
w.clicked = !w.clicked
// notify when we're done updating the frame.
notify = notifyInvalidate
}
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
-546
View File
@@ -1,546 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"archive/zip"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/sync/errgroup"
)
const (
minIOSVersion = 10
// Some Metal features require tvOS 11
minTVOSVersion = 11
// Metal is available from iOS 8 on devices, yet from version 13 on the
// simulator.
minSimulatorVersion = 13
)
func buildIOS(tmpDir, target string, bi *buildInfo) error {
appName := bi.name
switch *buildMode {
case "archive":
framework := *destPath
if framework == "" {
framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
}
return archiveIOS(tmpDir, target, framework, bi)
case "exe":
out := *destPath
if out == "" {
out = appName + ".ipa"
}
forDevice := strings.HasSuffix(out, ".ipa")
// Filter out unsupported architectures.
for i := len(bi.archs) - 1; i >= 0; i-- {
switch bi.archs[i] {
case "arm", "arm64":
if forDevice {
continue
}
case "386", "amd64":
if !forDevice {
continue
}
}
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
}
if !forDevice && !strings.HasSuffix(out, ".app") {
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
}
if !forDevice {
return exeIOS(tmpDir, target, out, bi)
}
payload := filepath.Join(tmpDir, "Payload")
appDir := filepath.Join(payload, appName+".app")
if err := os.MkdirAll(appDir, 0755); err != nil {
return err
}
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
return err
}
if err := signIOS(bi, tmpDir, appDir); err != nil {
return err
}
return zipDir(out, tmpDir, "Payload")
default:
panic("unreachable")
}
}
func signIOS(bi *buildInfo, tmpDir, app string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
provisions, err := filepath.Glob(provPattern)
if err != nil {
return err
}
provInfo := filepath.Join(tmpDir, "provision.plist")
var avail []string
for _, prov := range provisions {
// Decode the provision file to a plist.
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
if err != nil {
return err
}
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
if err != nil {
return err
}
exp, err := time.Parse(time.UnixDate, expUnix)
if err != nil {
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
}
if exp.Before(time.Now()) {
continue
}
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
if err != nil {
return err
}
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
if err != nil {
return err
}
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
avail = append(avail, provAppID)
if expAppID != provAppID {
continue
}
// Copy provisioning file.
embedded := filepath.Join(app, "embedded.mobileprovision")
if err := copyFile(embedded, prov); err != nil {
return err
}
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
if err != nil {
return err
}
// Omit trailing newline.
certDER = certDER[:len(certDER)-1]
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
if err != nil {
return err
}
entFile := filepath.Join(tmpDir, "entitlements.plist")
if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
return err
}
identity := sha1.Sum(certDER)
idHex := hex.EncodeToString(identity[:])
_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
return err
}
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
}
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
if bi.appID == "" {
return errors.New("app id is empty; use -appid to set it")
}
if err := os.RemoveAll(app); err != nil {
return err
}
if err := os.Mkdir(app, 0755); err != nil {
return err
}
appName := UppercaseName(bi.name)
exe := filepath.Join(app, appName)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
if err != nil {
return err
}
cflags = append(cflags,
"-fobjc-arc",
)
cflagsLine := strings.Join(cflags, " ")
exeSlice := filepath.Join(tmpDir, "app-"+a)
lipo.Args = append(lipo.Args, exeSlice)
compile := exec.Command(
"go",
"build",
"-ldflags=-s -w "+bi.ldflags,
"-o", exeSlice,
"-tags", bi.tags,
bi.pkgPath,
)
compile.Env = append(
os.Environ(),
"GOOS=ios",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS="+cflagsLine,
"CGO_LDFLAGS=-lresolv "+cflagsLine,
)
builds.Go(func() error {
_, err := runCmd(compile)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
infoPlist := buildInfoPlist(bi)
plistFile := filepath.Join(app, "Info.plist")
if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
return err
}
if _, err := os.Stat(bi.iconPath); err == nil {
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
if err != nil {
return err
}
// Merge assets plist with Info.plist
cmd := exec.Command(
"/usr/libexec/PlistBuddy",
"-c", "Merge "+assetPlist,
plistFile,
)
if _, err := runCmd(cmd); err != nil {
return err
}
}
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
return err
}
return nil
}
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
// iosIcons returns the asset plist file to be merged into Info.plist.
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
assets := filepath.Join(tmpDir, "Assets.xcassets")
if err := os.Mkdir(assets, 0700); err != nil {
return "", err
}
appIcon := filepath.Join(assets, "AppIcon.appiconset")
err := buildIcons(appIcon, icon, []iconVariant{
{path: "ios_2x.png", size: 120},
{path: "ios_3x.png", size: 180},
// The App Store icon is not allowed to contain
// transparent pixels.
{path: "ios_store.png", size: 1024, fill: true},
})
if err != nil {
return "", err
}
contentJson := `{
"images" : [
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_3x.png",
"scale" : "3x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "ios_store.png",
"scale" : "1x"
}
]
}`
contentFile := filepath.Join(appIcon, "Contents.json")
if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
return "", err
}
assetPlist := filepath.Join(tmpDir, "assets.plist")
minsdk := bi.minsdk
if minsdk == 0 {
minsdk = minIOSVersion
}
compile := exec.Command(
"actool",
"--compile", appDir,
"--platform", iosPlatformFor(bi.target),
"--minimum-deployment-target", strconv.Itoa(minsdk),
"--app-icon", "AppIcon",
"--output-partial-info-plist", assetPlist,
assets)
_, err = runCmd(compile)
return assetPlist, err
}
func buildInfoPlist(bi *buildInfo) string {
appName := UppercaseName(bi.name)
platform := iosPlatformFor(bi.target)
var supportPlatform string
switch bi.target {
case "ios":
supportPlatform = "iPhoneOS"
case "tvos":
supportPlatform = "AppleTVOS"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>%s</string>
<key>CFBundleIdentifier</key>
<string>%s</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>%s</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>%s</string>
<key>CFBundleVersion</key>
<string>%d</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>DTPlatformName</key>
<string>%s</string>
<key>DTPlatformVersion</key>
<string>12.4</string>
<key>MinimumOSVersion</key>
<string>%d</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>%s</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>16G73</string>
<key>DTSDKBuild</key>
<string>16G73</string>
<key>DTSDKName</key>
<string>%s12.4</string>
<key>DTXcode</key>
<string>1030</string>
<key>DTXcodeBuild</key>
<string>10G8</string>
</dict>
</plist>`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
}
func iosPlatformFor(target string) string {
switch target {
case "ios":
return "iphoneos"
case "tvos":
return "appletvos"
default:
panic("invalid platform " + target)
}
}
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
framework := filepath.Base(frameworkRoot)
const suf = ".framework"
if !strings.HasSuffix(framework, suf) {
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
}
framework = framework[:len(framework)-len(suf)]
if err := os.RemoveAll(frameworkRoot); err != nil {
return err
}
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
for _, dir := range []string{"Headers", "Modules"} {
p := filepath.Join(frameworkDir, dir)
if err := os.MkdirAll(p, 0755); err != nil {
return err
}
}
symlinks := [][2]string{
{"Versions/Current/Headers", "Headers"},
{"Versions/Current/Modules", "Modules"},
{"Versions/Current/" + framework, framework},
{"A", filepath.Join("Versions", "Current")},
}
for _, l := range symlinks {
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
return err
}
}
exe := filepath.Join(frameworkDir, framework)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
tags := bi.tags
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
if err != nil {
return err
}
lib := filepath.Join(tmpDir, "gio-"+a)
cmd := exec.Command(
"go",
"build",
"-ldflags=-s -w "+bi.ldflags,
"-buildmode=c-archive",
"-o", lib,
"-tags", tags,
bi.pkgPath,
)
lipo.Args = append(lipo.Args, lib)
cflagsLine := strings.Join(cflags, " ")
cmd.Env = append(
os.Environ(),
"GOOS=ios",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS="+cflagsLine,
"CGO_LDFLAGS="+cflagsLine,
)
builds.Go(func() error {
_, err := runCmd(cmd)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
if err != nil {
return err
}
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
headerSrc := filepath.Join(appDir, "framework_ios.h")
if err := copyFile(headerDst, headerSrc); err != nil {
return err
}
module := fmt.Sprintf(`framework module "%s" {
header "%[1]s.h"
export *
}`, framework)
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
return os.WriteFile(moduleFile, []byte(module), 0644)
}
func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
var (
platformSDK string
platformOS string
)
switch target {
case "ios":
platformOS = "ios"
platformSDK = "iphone"
case "tvos":
platformOS = "tvos"
platformSDK = "appletv"
}
switch arch {
case "arm", "arm64":
platformSDK += "os"
if minsdk == 0 {
minsdk = minIOSVersion
if target == "tvos" {
minsdk = minTVOSVersion
}
}
case "386", "amd64":
platformOS += "-simulator"
platformSDK += "simulator"
if minsdk == 0 {
minsdk = minSimulatorVersion
}
default:
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
}
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
if err != nil {
return "", nil, err
}
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
if err != nil {
return "", nil, err
}
cflags := []string{
"-fembed-bitcode",
"-arch", allArchs[arch].iosArch,
"-isysroot", sdkPath,
"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
}
return clang, cflags, nil
}
func zipDir(dst, base, dir string) (err error) {
f, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
zipf := zip.NewWriter(f)
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
rel := filepath.ToSlash(path[len(base)+1:])
entry, err := zipf.Create(rel)
if err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(entry, src)
return err
})
if err != nil {
return err
}
return zipf.Close()
}
-123
View File
@@ -1,123 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bytes"
"context"
"errors"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
)
type JSTestDriver struct {
driverBase
// ctx is the chromedp context.
ctx context.Context
}
func (d *JSTestDriver) Start(path string) {
if raceEnabled {
d.Skipf("js/wasm doesn't support -race; skipping")
}
// First, build the app.
dir := d.tempDir("gio-endtoend-js")
d.gogio("-target=js", "-o="+dir, path)
// Second, start Chrome.
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", *headless),
)
actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
d.Cleanup(cancel)
ctx, cancel := chromedp.NewContext(actx,
// Send all logf/errf calls to t.Logf
chromedp.WithLogf(d.Logf),
)
d.Cleanup(cancel)
d.ctx = ctx
if err := chromedp.Run(ctx); err != nil {
if errors.Is(err, exec.ErrNotFound) {
d.Skipf("test requires Chrome to be installed: %v", err)
return
}
d.Fatal(err)
}
pr, pw := io.Pipe()
d.Cleanup(func() { pw.Close() })
d.output = pr
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
switch ev.Type {
case "log", "info", "warning", "error":
var b bytes.Buffer
b.WriteString("console.")
b.WriteString(string(ev.Type))
b.WriteString("(")
for i, arg := range ev.Args {
if i > 0 {
b.WriteString(", ")
}
b.Write(arg.Value)
}
b.WriteString(")\n")
pw.Write(b.Bytes())
}
}
})
// Third, serve the app folder, set the browser tab dimensions, and
// navigate to the folder.
ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
d.Cleanup(ts.Close)
if err := chromedp.Run(ctx,
chromedp.EmulateViewport(int64(d.width), int64(d.height)),
chromedp.Navigate(ts.URL),
); err != nil {
d.Fatal(err)
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *JSTestDriver) Screenshot() image.Image {
var buf []byte
if err := chromedp.Run(d.ctx,
chromedp.CaptureScreenshot(&buf),
); err != nil {
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(buf))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *JSTestDriver) Click(x, y int) {
if err := chromedp.Run(d.ctx,
chromedp.MouseClickXY(float64(x), float64(y)),
); err != nil {
d.Fatal(err)
}
// Wait for the gio app to render after this click.
d.waitForFrame()
}
-200
View File
@@ -1,200 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"golang.org/x/tools/go/packages"
)
func buildJS(bi *buildInfo) error {
out := *destPath
if out == "" {
out = bi.name
}
if err := os.MkdirAll(out, 0700); err != nil {
return err
}
cmd := exec.Command(
"go",
"build",
"-ldflags="+bi.ldflags,
"-tags="+bi.tags,
"-o", filepath.Join(out, "main.wasm"),
bi.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=js",
"GOARCH=wasm",
)
_, err := runCmd(cmd)
if err != nil {
return err
}
var faviconPath string
if _, err := os.Stat(bi.iconPath); err == nil {
// Copy icon to the output folder
icon, err := os.ReadFile(bi.iconPath)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
return err
}
faviconPath = filepath.Base(bi.iconPath)
}
indexTemplate, err := template.New("").Parse(jsIndex)
if err != nil {
return err
}
var b bytes.Buffer
if err := indexTemplate.Execute(&b, struct {
Name string
Icon string
}{
Name: bi.name,
Icon: faviconPath,
}); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil {
return err
}
goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
if err != nil {
return err
}
wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
if _, err := os.Stat(wasmJS); err != nil {
return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
}
pkgs, err := packages.Load(&packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
}, bi.pkgPath)
if err != nil {
return err
}
extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
if err != nil {
return err
}
return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
}
func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
if len(p.GoFiles) == 0 {
return nil, nil
}
js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
if err != nil {
return nil, err
}
extraJS = append(extraJS, js...)
for _, imp := range p.Imports {
if !visited[imp.ID] {
extra, err := findPackagesJS(imp, visited)
if err != nil {
return nil, err
}
extraJS = append(extraJS, extra...)
visited[imp.ID] = true
}
}
return extraJS, nil
}
// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
// and append the jsStartGo.
func mergeJSFiles(dst string, files ...string) (err error) {
w, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := w.Close(); err != nil {
err = cerr
}
}()
_, err = io.Copy(w, strings.NewReader(jsSetGo))
if err != nil {
return err
}
for i := range files {
r, err := os.Open(files[i])
if err != nil {
return err
}
_, err = io.Copy(w, r)
r.Close()
if err != nil {
return err
}
}
_, err = io.Copy(w, strings.NewReader(jsStartGo))
return err
}
const (
jsIndex = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
{{ if .Icon }}<link rel="icon" href="{{.Icon}}" type="image/x-icon" />{{ end }}
{{ if .Name }}<title>{{.Name}}</title>{{ end }}
<script src="wasm.js"></script>
<style>
body,pre { margin:0;padding:0; }
</style>
</head>
<body>
</body>
</html>`
// jsSetGo sets the `window.go` variable.
jsSetGo = `(() => {
window.go = {argv: [], env: {}, importObject: {go: {}}};
const argv = new URLSearchParams(location.search).get("argv");
if (argv) {
window.go["argv"] = argv.split(" ");
}
})();`
// jsStartGo initializes the main.wasm.
jsStartGo = `(() => {
defaultGo = new Go();
Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
Object.assign(defaultGo["env"], go["env"]);
for (let key in go["importObject"]) {
if (typeof defaultGo["importObject"][key] === "undefined") {
defaultGo["importObject"][key] = {};
}
Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
}
window.go = defaultGo;
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
})();`
)
-262
View File
@@ -1,262 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
)
func buildMac(tmpDir string, bi *buildInfo) error {
builder := &macBuilder{TempDir: tmpDir}
builder.DestDir = *destPath
if builder.DestDir == "" {
builder.DestDir = bi.pkgPath
}
name := bi.name
if *destPath != "" {
if filepath.Ext(*destPath) != ".app" {
return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
}
name = filepath.Base(*destPath)
}
name = strings.TrimSuffix(name, ".app")
if bi.appID == "" {
return errors.New("app id is empty; use -appid to set it")
}
if err := builder.setIcon(bi.iconPath); err != nil {
return err
}
if err := builder.setInfo(bi, name); err != nil {
return fmt.Errorf("can't build the resources: %v", err)
}
for _, arch := range bi.archs {
tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir))
finalDest := builder.DestDir
if len(bi.archs) > 1 {
tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app")
finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app")
}
if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil {
return err
}
if bi.key != "" {
if err := builder.signProgram(bi, tmpDest, name, arch); err != nil {
return err
}
}
if err := dittozip(tmpDest, tmpDest+".zip"); err != nil {
return err
}
if bi.notaryAppleID != "" {
if err := builder.notarize(bi, tmpDest+".zip"); err != nil {
return err
}
}
if err := dittounzip(tmpDest+".zip", finalDest); err != nil {
return err
}
}
return nil
}
type macBuilder struct {
TempDir string
DestDir string
Icons []byte
Manifest []byte
Entitlements []byte
}
func (b *macBuilder) setIcon(path string) (err error) {
if _, err := os.Stat(path); err != nil {
return nil
}
out := filepath.Join(b.TempDir, "iconset.iconset")
if err := os.MkdirAll(out, 0777); err != nil {
return err
}
err = buildIcons(out, path, []iconVariant{
{path: "icon_512x512@2x.png", size: 1024},
{path: "icon_512x512.png", size: 512},
{path: "icon_256x256@2x.png", size: 512},
{path: "icon_256x256.png", size: 256},
{path: "icon_128x128@2x.png", size: 256},
{path: "icon_128x128.png", size: 128},
{path: "icon_64x64@2x.png", size: 128},
{path: "icon_64x64.png", size: 64},
{path: "icon_32x32@2x.png", size: 64},
{path: "icon_32x32.png", size: 32},
{path: "icon_16x16@2x.png", size: 32},
{path: "icon_16x16.png", size: 16},
})
if err != nil {
return err
}
cmd := exec.Command("iconutil",
"-c", "icns", out,
"-o", filepath.Join(b.TempDir, "icon.icns"))
if _, err := runCmd(cmd); err != nil {
return err
}
b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
return err
}
func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{{.Name}}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>{{.Bundle}}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>`)
if err != nil {
return err
}
var manifest bufferCoff
if err := t.Execute(&manifest, struct {
Name, Bundle string
}{
Name: name,
Bundle: buildInfo.appID,
}); err != nil {
return err
}
b.Manifest = manifest.Bytes()
b.Entitlements = []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>`)
return nil
}
func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil {
return err
}
}
if len(b.Icons) > 0 {
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
return err
}
}
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
return err
}
cmd := exec.Command(
"go",
"build",
"-ldflags="+buildInfo.ldflags,
"-tags="+buildInfo.tags,
"-o", filepath.Join(binDest, "/Contents/MacOS/"+name),
buildInfo.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=darwin",
"GOARCH="+arch,
"CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
)
_, err := runCmd(cmd)
return err
}
func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
options := filepath.Join(b.TempDir, "ent.ent")
if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
return err
}
xattr := exec.Command("xattr", "-rc", binDest)
if _, err := runCmd(xattr); err != nil {
return err
}
cmd := exec.Command(
"codesign",
"--deep",
"--force",
"--options", "runtime",
"--entitlements", options,
"--sign", buildInfo.key,
binDest,
)
_, err := runCmd(cmd)
return err
}
func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error {
cmd := exec.Command(
"xcrun",
"notarytool",
"submit",
binDest,
"--apple-id", buildInfo.notaryAppleID,
"--team-id", buildInfo.notaryTeamID,
"--wait",
)
if buildInfo.notaryPassword != "" {
cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword)
}
_, err := runCmd(cmd)
return err
}
func dittozip(input, output string) error {
cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output)
_, err := runCmd(cmd)
return err
}
func dittounzip(input, output string) error {
cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output)
_, err := runCmd(cmd)
return err
}
-230
View File
@@ -1,230 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/image/draw"
"golang.org/x/sync/errgroup"
)
var (
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android")
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
name = flag.String("name", "", "app name (for -buildmode=exe)")
version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode")
printCommands = flag.Bool("x", false, "print the commands")
keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker")
extraTags = flag.String("tags", "", "extra tags to the Go tool")
iconPath = flag.String("icon", "", "specify an icon for iOS and Android")
signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.")
notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.")
notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.")
notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.")
)
func main() {
flag.Usage = func() {
fmt.Fprint(os.Stderr, mainUsage)
}
flag.Parse()
if err := flagValidate(); err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
buildInfo, err := newBuildInfo(flag.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
if err := build(buildInfo); err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func flagValidate() error {
pkgPathArg := flag.Arg(0)
if pkgPathArg == "" {
return errors.New("specify a package")
}
if *target == "" {
return errors.New("please specify -target")
}
switch *target {
case "ios", "tvos", "android", "js", "windows", "macos":
default:
return fmt.Errorf("invalid -target %s", *target)
}
switch *buildMode {
case "archive", "exe":
default:
return fmt.Errorf("invalid -buildmode %s", *buildMode)
}
return nil
}
func build(bi *buildInfo) error {
tmpDir, err := os.MkdirTemp("", "gogio-")
if err != nil {
return err
}
if *keepWorkdir {
fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
} else {
defer os.RemoveAll(tmpDir)
}
switch *target {
case "js":
return buildJS(bi)
case "ios", "tvos":
return buildIOS(tmpDir, *target, bi)
case "android":
return buildAndroid(tmpDir, bi)
case "windows":
return buildWindows(tmpDir, bi)
case "macos":
return buildMac(tmpDir, bi)
default:
panic("unreachable")
}
}
func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
if *printCommands {
fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
}
out, err := cmd.Output()
if err == nil {
return out, nil
}
if err, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
}
return nil, err
}
func runCmd(cmd *exec.Cmd) (string, error) {
out, err := runCmdRaw(cmd)
return string(bytes.TrimSpace(out)), err
}
func copyFile(dst, src string) (err error) {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := w.Close(); err == nil {
err = cerr
}
}()
_, err = io.Copy(w, r)
return err
}
type arch struct {
iosArch string
jniArch string
clangArch string
}
var allArchs = map[string]arch{
"arm": {
iosArch: "armv7",
jniArch: "armeabi-v7a",
clangArch: "armv7a-linux-androideabi",
},
"arm64": {
iosArch: "arm64",
jniArch: "arm64-v8a",
clangArch: "aarch64-linux-android",
},
"386": {
iosArch: "i386",
jniArch: "x86",
clangArch: "i686-linux-android",
},
"amd64": {
iosArch: "x86_64",
jniArch: "x86_64",
clangArch: "x86_64-linux-android",
},
}
type iconVariant struct {
path string
size int
fill bool
}
func buildIcons(baseDir, icon string, variants []iconVariant) error {
f, err := os.Open(icon)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
var resizes errgroup.Group
for _, v := range variants {
v := v
resizes.Go(func() (err error) {
path := filepath.Join(baseDir, v.path)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
return png.Encode(f, resizeIcon(v, img))
})
}
return resizes.Wait()
}
func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
op := draw.Src
if v.fill {
op = draw.Over
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
}
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
return scaled
}
-17
View File
@@ -1,17 +0,0 @@
package main
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
if os.Getenv("RUN_GOGIO") != "" {
// Allow the end-to-end tests to call the gogio tool without
// having to build it from scratch, nor having to refactor the
// main function to avoid using global variables.
main()
os.Exit(0) // main already exits, but just in case.
}
os.Exit(m.Run())
}
-36
View File
@@ -1,36 +0,0 @@
package main
var AndroidPermissions = map[string][]string{
"network": {
"android.permission.INTERNET",
},
"networkstate": {
"android.permission.ACCESS_NETWORK_STATE",
},
"bluetooth": {
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.ACCESS_FINE_LOCATION",
},
"camera": {
"android.permission.CAMERA",
},
"storage": {
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
},
"wakelock": {
"android.permission.WAKE_LOCK",
},
}
var AndroidFeatures = map[string][]string{
"default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
"bluetooth": {
`name="android.hardware.bluetooth"`,
`name="android.hardware.bluetooth_le"`,
},
"camera": {
`name="android.hardware.camera"`,
},
}
-8
View File
@@ -1,8 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
//go:build race
// +build race
package main_test
func init() { raceEnabled = true }
-196
View File
@@ -1,196 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bufio"
"bytes"
"context"
"fmt"
"image"
"image/png"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"text/template"
"time"
)
type WaylandTestDriver struct {
driverBase
runtimeDir string
socket string
display string
}
// No bars or anything fancy. Just a white background with our dimensions.
var tmplSwayConfig = template.Must(template.New("").Parse(`
output * bg #FFFFFF solid_color
output * mode {{.Width}}x{{.Height}}
default_border none
`))
var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`)
func (d *WaylandTestDriver) Start(path string) {
// We want os.Environ, so that it can e.g. find $DISPLAY to run within
// X11. wlroots env vars are documented at:
// https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md
env := os.Environ()
if *headless {
env = append(env, "WLR_BACKENDS=headless")
}
d.needPrograms(
"sway", // to run a wayland compositor
"grim", // to take screenshots
"swaymsg", // to send input
)
// First, build the app.
dir := d.tempDir("gio-endtoend-wayland")
bin := filepath.Join(dir, "red")
flags := []string{"build", "-tags", "nox11", "-o=" + bin}
if raceEnabled {
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
conf := filepath.Join(dir, "config")
f, err := os.Create(conf)
if err != nil {
d.Fatal(err)
}
defer f.Close()
if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{
d.width, d.height,
}); err != nil {
d.Fatal(err)
}
d.socket = filepath.Join(dir, "socket")
env = append(env, "SWAYSOCK="+d.socket)
d.runtimeDir = dir
env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir)
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
// First, start sway.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose")
cmd.Env = env
stderr, err := cmd.StderrPipe()
if err != nil {
d.Fatal(err)
}
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
d.Cleanup(func() {
// Give it a chance to exit gracefully, cleaning up
// after itself. After 10ms, the deferred cancel above
// will signal an os.Kill.
cmd.Process.Signal(os.Interrupt)
time.Sleep(10 * time.Millisecond)
})
// Wait for sway to be ready. We probably don't need a deadline
// here.
br := bufio.NewReader(stderr)
for {
line, err := br.ReadString('\n')
if err != nil {
d.Fatal(err)
}
if m := rxSwayReady.FindStringSubmatch(line); m != nil {
d.display = m[1]
break
}
}
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") {
// Don't print all stderr, since we use --verbose.
// TODO(mvdan): if it's useful, probably filter
// errors and show them.
d.Error(err)
}
wg.Done()
}()
}
// Then, start our program on the sway compositor above.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin)
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *WaylandTestDriver) Screenshot() image.Image {
cmd := exec.Command("grim", "/dev/stdout")
cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *WaylandTestDriver) swaymsg(args ...interface{}) {
strs := []string{"--socket", d.socket}
for _, arg := range args {
strs = append(strs, fmt.Sprint(arg))
}
cmd := exec.Command("swaymsg", strs...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
}
func (d *WaylandTestDriver) Click(x, y int) {
d.swaymsg("seat", "-", "cursor", "set", x, y)
d.swaymsg("seat", "-", "cursor", "press", "button1")
d.swaymsg("seat", "-", "cursor", "release", "button1")
// Wait for the gio app to render after this click.
d.waitForFrame()
}
-152
View File
@@ -1,152 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"context"
"image"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
"golang.org/x/image/draw"
)
// Wine is tightly coupled with X11 at the moment, and we can reuse the same
// methods to automate screenshots and clicks. The main difference is how we
// build and run the app.
// The only quirk is that it seems impossible for the Wine window to take the
// entirety of the X server's dimensions, even if we try to resize it to take
// the entire display. It seems to want to leave some vertical space empty,
// presumably for window decorations or the "start" bar on Windows. To work
// around that, make the X server 50x50px bigger, and crop the screenshots back
// to the original size.
type WineTestDriver struct {
X11TestDriver
}
func (d *WineTestDriver) Start(path string) {
d.needPrograms("wine")
// First, build the app.
bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe")
flags := []string{"build", "-o=" + bin}
if raceEnabled {
if runtime.GOOS != "windows" {
// cross-compilation disables CGo, which breaks -race.
d.Skipf("can't cross-compile -race for Windows; skipping")
}
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS=windows")
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
// Add 50x50px to the display dimensions, as discussed earlier.
d.startServer(&wg, d.width+50, d.height+50)
// Then, start our program via Wine on the X server above.
{
cacheDir, err := os.UserCacheDir()
if err != nil {
d.Fatal(err)
}
// Use a wine directory separate from the default ~/.wine, so
// that the user's winecfg doesn't affect our test. This will
// default to ~/.cache/gio-e2e-wine. We use the user's cache,
// to reuse a previously set up wineprefix.
wineprefix := filepath.Join(cacheDir, "gio-e2e-wine")
// First, ensure that wineprefix is up to date with wineboot.
// Wait for this separately from the first frame, as setting up
// a new prefix might take 5s on its own.
env := []string{
"DISPLAY=" + d.display,
"WINEDEBUG=fixme-all", // hide "fixme" noise
"WINEPREFIX=" + wineprefix,
// Disable wine-gecko (Explorer) and wine-mono (.NET).
// Otherwise, if not installed, wineboot will get stuck
// with a prompt to install them on the virtual X
// display. Moreover, Gio doesn't need either, and wine
// is faster without them.
"WINEDLLOVERRIDES=mscoree,mshtml=",
}
{
start := time.Now()
cmd := exec.Command("wine", "wineboot", "-i")
cmd.Env = env
// Use a combined output pipe instead of CombinedOutput,
// so that we only wait for the child process to exit,
// and we don't need to wait for all of wine's
// grandchildren to exit and stop writing. This is
// relevant as wine leaves "wineserver" lingering for
// three seconds by default, to be reused later.
stdout, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
if err := cmd.Run(); err != nil {
io.Copy(os.Stderr, stdout)
d.Fatal(err)
}
d.Logf("set up WINEPREFIX in %s", time.Since(start))
}
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "wine", bin)
cmd.Env = env
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
// xdotool seems to fail at actually moving the window if we use it
// immediately after Gio is ready. Why?
// We can't tell if the windowmove operation worked until we take a
// screenshot, because the getwindowgeometry op reports the 0x0
// coordinates even if the window wasn't moved properly.
// A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that.
// TODO(mvdan): revisit this, when you have a spare three hours.
time.Sleep(400 * time.Millisecond)
id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio")
d.xdotool("windowmove", "--sync", id, 0, 0)
}
func (d *WineTestDriver) Screenshot() image.Image {
img := d.X11TestDriver.Screenshot()
// Crop the screenshot back to the original dimensions.
cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height))
draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src)
return cropped
}
-410
View File
@@ -1,410 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"text/template"
"github.com/akavel/rsrc/binutil"
"github.com/akavel/rsrc/coff"
"golang.org/x/text/encoding/unicode"
)
func buildWindows(tmpDir string, bi *buildInfo) error {
builder := &windowsBuilder{TempDir: tmpDir}
builder.DestDir = *destPath
if builder.DestDir == "" {
builder.DestDir = bi.pkgPath
}
name := bi.name
if *destPath != "" {
if filepath.Ext(*destPath) != ".exe" {
return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
}
name = filepath.Base(*destPath)
}
name = strings.TrimSuffix(name, ".exe")
sdk := bi.minsdk
if sdk > 10 {
return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
}
for _, arch := range bi.archs {
builder.Coff = coff.NewRSRC()
builder.Coff.Arch(arch)
if err := builder.embedIcon(bi.iconPath); err != nil {
return err
}
if err := builder.embedManifest(windowsManifest{
Version: bi.version.String(),
WindowsVersion: sdk,
Name: name,
}); err != nil {
return fmt.Errorf("can't create manifest: %v", err)
}
if err := builder.embedInfo(windowsResources{
Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)},
VersionHuman: bi.version.String(),
Name: name,
Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10)
}); err != nil {
return fmt.Errorf("can't create info: %v", err)
}
if err := builder.buildResource(bi, name, arch); err != nil {
return fmt.Errorf("can't build the resources: %v", err)
}
if err := builder.buildProgram(bi, name, arch); err != nil {
return err
}
}
return nil
}
type (
windowsResources struct {
Version [2]uint32
VersionHuman string
Language uint16
Name string
}
windowsManifest struct {
Version string
WindowsVersion int
Name string
}
windowsBuilder struct {
TempDir string
DestDir string
Coff *coff.Coff
}
)
const (
// https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types
windowsResourceIcon = 3
windowsResourceIconGroup = windowsResourceIcon + 11
windowsResourceManifest = 24
windowsResourceVersion = 16
)
type bufferCoff struct {
bytes.Buffer
}
func (b *bufferCoff) Size() int64 {
return int64(b.Len())
}
func (b *windowsBuilder) embedIcon(path string) (err error) {
iconFile, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("can't read the icon located at %s: %v", path, err)
}
defer iconFile.Close()
iconImage, err := png.Decode(iconFile)
if err != nil {
return fmt.Errorf("can't decode the PNG file (%s): %v", path, err)
}
sizes := []int{16, 32, 48, 64, 128, 256}
var iconHeader bufferCoff
// GRPICONDIR structure.
if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
return err
}
for _, size := range sizes {
var iconBuffer bufferCoff
if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil {
return fmt.Errorf("can't encode image: %v", err)
}
b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer)
if err := binary.Write(&iconHeader, binary.LittleEndian, struct {
Size [2]uint8
Color [2]uint8
Planes uint16
BitCount uint16
Length uint32
Id uint16
}{
Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px.
Planes: 1,
BitCount: 32,
Length: uint32(iconBuffer.Len()),
Id: uint16(size),
}); err != nil {
return err
}
}
b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader)
return nil
}
func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error {
out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso"))
if err != nil {
return err
}
defer out.Close()
b.Coff.Freeze()
// See https://github.com/akavel/rsrc/internal/write.go#L13.
w := binutil.Writer{W: out}
binutil.Walk(b.Coff, func(v reflect.Value, path string) error {
if binutil.Plain(v.Kind()) {
w.WriteLE(v.Interface())
return nil
}
vv, ok := v.Interface().(binutil.SizedReader)
if ok {
w.WriteFromSized(vv)
return binutil.WALK_SKIP
}
return nil
})
if w.Err != nil {
return fmt.Errorf("error writing output file: %s", w.Err)
}
return nil
}
func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
dest := b.DestDir
if len(buildInfo.archs) > 1 {
dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe")
}
cmd := exec.Command(
"go",
"build",
"-ldflags=-H=windowsgui "+buildInfo.ldflags,
"-tags="+buildInfo.tags,
"-o", dest,
buildInfo.pkgPath,
)
cmd.Env = append(
os.Environ(),
"GOOS=windows",
"GOARCH="+arch,
)
_, err := runCmd(cmd)
return err
}
func (b *windowsBuilder) embedManifest(v windowsManifest) error {
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="{{.Name}}" version="{{.Version}}" />
<description>{{.Name}}</description>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
{{if (le .WindowsVersion 10)}}<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
{{end}}
{{if (le .WindowsVersion 9)}}<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
{{end}}
{{if (le .WindowsVersion 8)}}<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
{{end}}
{{if (le .WindowsVersion 7)}}<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
{{end}}
{{if (le .WindowsVersion 6)}}<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
{{end}}
</application>
</compatibility>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>`)
if err != nil {
return err
}
var manifest bufferCoff
if err := t.Execute(&manifest, v); err != nil {
return err
}
b.Coff.AddResource(windowsResourceManifest, 1, &manifest)
return nil
}
func (b *windowsBuilder) embedInfo(v windowsResources) error {
page := uint16(1)
// https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo
t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo
windowsInfoValueFixed{
Signature: 0xFEEF04BD,
StructVersion: 0x00010000,
FileVersion: v.Version,
ProductVersion: v.Version,
FileFlagMask: 0x3F,
FileFlags: 0,
FileOS: 0x40004,
FileType: 0x1,
FileSubType: 0,
},
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo
newValue(valueText, "StringFileInfo", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable
newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str
newValue(valueText, "ProductVersion", v.VersionHuman),
newValue(valueText, "FileVersion", v.VersionHuman),
newValue(valueText, "FileDescription", v.Name),
newValue(valueText, "ProductName", v.Name),
// TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...)
}),
}),
// https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo
newValue(valueBinary, "VarFileInfo", []io.WriterTo{
// https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str
newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)),
}),
})
// For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`:
t.ValueLength = 52
var verrsrc bufferCoff
if _, err := t.WriteTo(&verrsrc); err != nil {
return err
}
b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc)
return nil
}
type windowsInfoValueFixed struct {
Signature uint32
StructVersion uint32
FileVersion [2]uint32
ProductVersion [2]uint32
FileFlagMask uint32
FileFlags uint32
FileOS uint32
FileType uint32
FileSubType uint32
FileDate [2]uint32
}
func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) {
return 0, binary.Write(w, binary.LittleEndian, v)
}
type windowsInfoValue struct {
Length uint16
ValueLength uint16
Type uint16
Key []byte
Value []byte
}
func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) {
// binary.Write doesn't support []byte inside struct.
if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil {
return 0, err
}
if _, err = w.Write(v.Key); err != nil {
return 0, err
}
if _, err = w.Write(v.Value); err != nil {
return 0, err
}
return 0, nil
}
const (
valueBinary uint16 = 0
valueText uint16 = 1
)
func newValue(valueType uint16, key string, input interface{}) windowsInfoValue {
v := windowsInfoValue{
Type: valueType,
Length: 6,
}
padding := func(in []byte) []byte {
if l := uint16(len(in)) + v.Length; l%4 != 0 {
return append(in, make([]byte, 4-l%4)...)
}
return in
}
v.Key = padding(utf16Encode(key))
v.Length += uint16(len(v.Key))
switch in := input.(type) {
case string:
v.Value = padding(utf16Encode(in))
v.ValueLength = uint16(len(v.Value) / 2)
case []io.WriterTo:
var buff bytes.Buffer
for k := range in {
if _, err := in[k].WriteTo(&buff); err != nil {
panic(err)
}
}
v.Value = buff.Bytes()
default:
var buff bytes.Buffer
if err := binary.Write(&buff, binary.LittleEndian, in); err != nil {
panic(err)
}
v.ValueLength = uint16(buff.Len())
v.Value = buff.Bytes()
}
v.Length += uint16(len(v.Value))
return v
}
// utf16Encode encodes the string to UTF16 with null-termination.
func utf16Encode(s string) []byte {
b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s))
if err != nil {
panic(err)
}
return append(b, 0x00, 0x00) // null-termination.
}
-170
View File
@@ -1,170 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main_test
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
type X11TestDriver struct {
driverBase
display string
}
func (d *X11TestDriver) Start(path string) {
// First, build the app.
bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red")
flags := []string{"build", "-tags", "nowayland", "-o=" + bin}
if raceEnabled {
flags = append(flags, "-race")
}
flags = append(flags, path)
cmd := exec.Command("go", flags...)
if out, err := cmd.CombinedOutput(); err != nil {
d.Fatalf("could not build app: %s:\n%s", err, out)
}
var wg sync.WaitGroup
d.Cleanup(wg.Wait)
d.startServer(&wg, d.width, d.height)
// Then, start our program on the X server above.
{
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin)
cmd.Env = []string{"DISPLAY=" + d.display}
output, err := cmd.StdoutPipe()
if err != nil {
d.Fatal(err)
}
cmd.Stderr = cmd.Stdout
d.output = output
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
d.Error(err)
}
wg.Done()
}()
}
// Wait for the gio app to render.
d.waitForFrame()
}
func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) {
// Pick a random display number between 1 and 100,000. Most machines
// will only be using :0, so there's only a 0.001% chance of two
// concurrent test runs to run into a conflict.
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1)
var xprog string
xflags := []string{
"-wr", // we want a white background; the default is black
}
if *headless {
xprog = "Xvfb" // virtual X server
xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height))
} else {
xprog = "Xephyr" // nested X server as a window
xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height))
}
xflags = append(xflags, d.display)
d.needPrograms(
xprog, // to run the X server
"scrot", // to take screenshots
"xdotool", // to send input
)
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, xprog, xflags...)
combined := &bytes.Buffer{}
cmd.Stdout = combined
cmd.Stderr = combined
if err := cmd.Start(); err != nil {
d.Fatal(err)
}
d.Cleanup(cancel)
d.Cleanup(func() {
// Give it a chance to exit gracefully, cleaning up
// after itself. After 10ms, the deferred cancel above
// will signal an os.Kill.
cmd.Process.Signal(os.Interrupt)
time.Sleep(10 * time.Millisecond)
})
// Wait for the X server to be ready. The socket path isn't
// terribly portable, but that's okay for now.
withRetries(d.T, time.Second, func() error {
socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:])
_, err := os.Stat(socket)
return err
})
wg.Add(1)
go func() {
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
// Print all output and error.
io.Copy(os.Stdout, combined)
d.Error(err)
}
wg.Done()
}()
}
func (d *X11TestDriver) Screenshot() image.Image {
cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout")
cmd.Env = []string{"DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
d.Fatal(err)
}
return img
}
func (d *X11TestDriver) xdotool(args ...interface{}) string {
d.Helper()
strs := make([]string, len(args))
for i, arg := range args {
strs[i] = fmt.Sprint(arg)
}
cmd := exec.Command("xdotool", strs...)
cmd.Env = []string{"DISPLAY=" + d.display}
out, err := cmd.CombinedOutput()
if err != nil {
d.Errorf("%s", out)
d.Fatal(err)
}
return string(bytes.TrimSpace(out))
}
func (d *X11TestDriver) Click(x, y int) {
d.xdotool("mousemove", "--sync", x, y)
d.xdotool("click", "1")
// Wait for the gio app to render after this click.
d.waitForFrame()
}
-582
View File
@@ -1,582 +0,0 @@
// SPDX-License-Identifier: Unlicense OR MIT
// Command svg2gio converts SVG files to Gio functions. Only a limited subset of
// SVG files are supported.
package main
import (
"bytes"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"go/format"
"gioui.org/f32"
)
var (
pkg = flag.String("pkg", "", "Go package")
output = flag.String("o", "svg.go", "Output Go file")
)
func main() {
flag.Parse()
if *pkg == "" {
fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n")
os.Exit(1)
}
args := flag.Args()
if err := convertAll(args); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(2)
}
}
type Points []float32
func (p *Points) UnmarshalText(text []byte) error {
for {
text = bytes.TrimLeft(text, "\t\n")
if len(text) == 0 {
break
}
var num []byte
end := bytes.IndexAny(text, " ,")
if end != -1 {
num = text[:end]
text = text[end+1:]
} else {
num = text
text = nil
}
f, err := strconv.ParseFloat(string(num), 32)
if err != nil {
return err
}
*p = append(*p, float32(f))
}
return nil
}
type Transform f32.Affine2D
func (t *Transform) UnmarshalText(text []byte) error {
switch {
case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")):
trans := text[7 : len(text)-1]
var p Points
if err := p.UnmarshalText(trans); err != nil {
return err
}
if len(p) != 6 {
return fmt.Errorf("malformed transform matrix: %q", text)
}
*t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5]))
return nil
default:
return fmt.Errorf("unsupported transform: %q", text)
}
}
type Fill struct {
Transform Transform `xml:"transform,attr"`
Fill Color `xml:"fill,attr"`
Stroke Color `xml:"stroke,attr"`
StrokeLinejoin string `xml:"stroke-linejoin,attr"`
StrokeLinecap string `xml:"stroke-linecap,attr"`
StrokeWidth float32 `xml:"stroke-width,attr"`
}
type Color struct {
Set bool
Value int
}
func (c *Color) UnmarshalText(text []byte) error {
if string(text) == "none" {
*c = Color{}
return nil
}
if !bytes.HasPrefix(text, []byte("#")) {
return fmt.Errorf("invalid color: %q", text)
}
text = text[1:]
i, err := strconv.ParseInt(string(text), 16, 32)
// Implied alpha.
if len(text) == 6 {
i |= 0xff000000
}
*c = Color{
Set: true,
Value: int(i),
}
return err
}
func convertAll(files []string) error {
w := new(bytes.Buffer)
fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n")
fmt.Fprintf(w, "package %s\n\n", *pkg)
fmt.Fprintf(w, "import \"image/color\"\n")
fmt.Fprintf(w, "import \"math\"\n")
fmt.Fprintf(w, "import \"gioui.org/op\"\n")
fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n")
fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n")
fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n")
fmt.Fprintf(w, "var ops op.Ops\n\n")
fmt.Fprintf(w, funcs)
for _, filename := range files {
if err := convert(w, filename); err != nil {
return err
}
}
src, err := format.Source(w.Bytes())
if err != nil {
return err
}
return os.WriteFile(*output, src, 0o660)
}
func convert(w io.Writer, filename string) error {
base := filepath.Base(filename)
ext := filepath.Ext(base)
name := "Image_" + base[:len(base)-len(ext)]
fmt.Fprintf(w, "var %s struct {\n", name)
fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n")
fmt.Fprintf(w, "Call op.CallOp\n\n")
fmt.Fprintf(w, "}\n")
fmt.Fprintf(w, "func init() {\n")
defer fmt.Fprintf(w, "}\n")
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
d := xml.NewDecoder(f)
if err := parse(w, d, name); err != nil {
line, col := d.InputPos()
return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err)
}
return nil
}
func parse(w io.Writer, d *xml.Decoder, name string) error {
for {
tok, err := d.Token()
if err != nil {
if err == io.EOF {
return errors.New("unexpected end of file")
}
return err
}
switch tok := tok.(type) {
case xml.StartElement:
if n := tok.Name.Local; n != "svg" {
return fmt.Errorf("invalid SVG root: <%s>", n)
}
if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" {
return fmt.Errorf("unsupported SVG namespace: %s", n)
}
fmt.Fprintf(w, "m := op.Record(&ops)\n")
defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name)
for _, a := range tok.Attr {
if a.Name.Local == "viewBox" {
var p Points
if err := p.UnmarshalText([]byte(a.Value)); err != nil {
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
}
if len(p) != 4 {
return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
}
fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1])))
fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3])))
}
}
return parseSVG(w, d)
}
}
}
func point(p f32.Point) string {
return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y)
}
type Poly struct {
XMLName xml.Name
Points Points `xml:"points,attr"`
Fill
}
func (p *Poly) Path(w io.Writer) error {
if len(p.Points) <= 1 {
return nil
}
pen := f32.Pt(p.Points[0], p.Points[1])
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen))
last := pen
for i := 2; i < len(p.Points); i += 2 {
last = f32.Pt(p.Points[i], p.Points[i+1])
fmt.Fprintf(w, "p.LineTo(%s)\n", point(last))
}
if p.XMLName.Local == "polygon" && last != pen {
fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen))
}
return nil
}
type Path struct {
D string `xml:"d,attr"`
Fill
}
func (p *Path) Path(w io.Writer) error {
return printPathCommands(w, p.D)
}
type Line struct {
X1 float32 `xml:"x1,attr"`
Y1 float32 `xml:"y1,attr"`
X2 float32 `xml:"x2,attr"`
Y2 float32 `xml:"y2,attr"`
Fill
}
func (l *Line) Path(w io.Writer) error {
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1)))
fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2)))
return nil
}
type Ellipse struct {
Cx float32 `xml:"cx,attr"`
Cy float32 `xml:"cy,attr"`
Rx float32 `xml:"rx,attr"`
Ry float32 `xml:"ry,attr"`
Fill
}
func (e *Ellipse) Path(w io.Writer) error {
c := f32.Pt(e.Cx, e.Cy)
r := f32.Pt(e.Rx, e.Ry)
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r))
return nil
}
type Rect struct {
X float32 `xml:"x,attr"`
Y float32 `xml:"y,attr"`
Width float32 `xml:"width,attr"`
Height float32 `xml:"height,attr"`
Fill
}
func (r *Rect) Path(w io.Writer) error {
o := f32.Pt(r.X, r.Y)
sz := f32.Pt(r.Width, r.Height)
fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz))
return nil
}
type Circle struct {
Cx float32 `xml:"cx,attr"`
Cy float32 `xml:"cy,attr"`
R float32 `xml:"r,attr"`
Fill
}
func (c *Circle) Path(w io.Writer) error {
center := f32.Pt(c.Cx, c.Cy)
r := f32.Pt(c.R, c.R)
fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r))
return nil
}
func parseSVG(w io.Writer, d *xml.Decoder) error {
for {
tok, err := d.Token()
if err != nil {
if err == io.EOF {
return errors.New("unexpected end of <svg> element")
}
return err
}
var start xml.StartElement
switch tok := tok.(type) {
case xml.EndElement:
return nil
case xml.StartElement:
start = tok
default:
continue
}
var elem interface {
Path(w io.Writer) error
}
var fill *Fill
switch n := start.Name.Local; n {
case "g":
// Flatten groups.
if err := parseSVG(w, d); err != nil {
return err
}
continue
case "title":
d.Skip()
continue
case "polygon", "polyline":
p := new(Poly)
elem = p
fill = &p.Fill
case "path":
p := new(Path)
elem = p
fill = &p.Fill
case "line":
l := new(Line)
elem = l
fill = &l.Fill
case "ellipse":
e := new(Ellipse)
elem = e
fill = &e.Fill
case "rect":
r := new(Rect)
elem = r
fill = &r.Fill
case "circle":
c := new(Circle)
elem = c
fill = &c.Fill
default:
return fmt.Errorf("unsupported tag: <%s>", n)
}
if err := d.DecodeElement(elem, &start); err != nil {
return err
}
if !fill.Fill.Set && !fill.Stroke.Set {
continue
}
fmt.Fprintf(w, "{\n")
trans := f32.Affine2D(fill.Transform)
if trans != (f32.Affine2D{}) {
sx, hx, ox, sy, hy, oy := trans.Elems()
fmt.Fprintf(w, "t := op.Affine(f32.NewAffine2D(%g, %g, %g, %g, %g, %g)).Push(&ops)\n", sx, hx, ox, sy, hy, oy)
}
fmt.Fprintf(w, "var p clip.Path\n")
fmt.Fprintf(w, "p.Begin(&ops)\n")
if err := elem.Path(w); err != nil {
return err
}
fmt.Fprintf(w, "spec := p.End()\n")
if fill.Fill.Set {
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Outline{Path: spec}.Op())\n", fill.Fill.Value)
}
if fill.Stroke.Set {
fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Stroke{Width: %g, Path: spec}.Op())\n", fill.Stroke.Value, fill.StrokeWidth)
}
if trans != (f32.Affine2D{}) {
fmt.Fprintf(w, "t.Pop()\n")
}
fmt.Fprintf(w, "}\n")
}
}
func printPathCommands(w io.Writer, cmds string) error {
moveTo := func(p f32.Point) {
fmt.Fprintf(w, "p.MoveTo(%s)\n", point(p))
}
lineTo := func(p f32.Point) {
fmt.Fprintf(w, "p.LineTo(%s)\n", point(p))
}
cubeTo := func(p0, p1, p2 f32.Point) {
fmt.Fprintf(w, "p.CubeTo(%s, %s, %s)\n", point(p0), point(p1), point(p2))
}
cmds = strings.TrimSpace(cmds)
var pen f32.Point
initPoint := pen
ctrl2 := pen
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
orig := cmds
op := rune(cmds[0])
cmds = cmds[1:]
switch op {
case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's':
case 'Z', 'z':
if pen != initPoint {
lineTo(initPoint)
pen = initPoint
}
ctrl2 = initPoint
continue
default:
return fmt.Errorf("unknown <path> command %s in %q", string(op), orig)
}
var coords []float64
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
n, x, ok := parseFloat(cmds)
if !ok {
break
}
cmds = cmds[n:]
coords = append(coords, x)
}
rel := unicode.IsLower(op)
newPen := pen
switch unicode.ToLower(op) {
case 'h':
for _, x := range coords {
p := f32.Pt(float32(x), pen.Y)
if rel {
p.X += pen.X
}
lineTo(p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
case 'v':
for _, y := range coords {
p := f32.Pt(pen.X, float32(y))
if rel {
p.Y += pen.Y
}
lineTo(p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
}
if len(coords)%2 != 0 {
return fmt.Errorf("odd number of coordinates in <path> data: %q", orig)
}
var off f32.Point
if rel {
// Relative command.
off = pen
} else {
off = f32.Pt(0, 0)
}
var points []f32.Point
for i := 0; i < len(coords); i += 2 {
p := f32.Pt(float32(coords[i]), float32(coords[i+1]))
p = p.Add(off)
points = append(points, p)
}
newCtrl2 := ctrl2
switch op := unicode.ToLower(op); op {
case 'm', 'l':
sop := moveTo
if op == 'l' {
sop = lineTo
}
for _, p := range points {
sop(p)
newPen = p
}
if op == 'm' {
initPoint = newPen
}
case 'c':
for i := 0; i < len(points); i += 3 {
p1, p2, p3 := points[i], points[i+1], points[i+2]
cubeTo(p1, p2, p3)
newPen = p3
newCtrl2 = p2
}
case 's':
for i := 0; i < len(points); i += 2 {
p2, p3 := points[i], points[i+1]
// Compute p1 by reflecting p2 on to the line that contains pen and p2.
p1 := pen.Mul(2).Sub(ctrl2)
cubeTo(p1, p2, p3)
newPen = p3
newCtrl2 = p2
}
}
pen = newPen
ctrl2 = newCtrl2
}
return nil
}
func parseFloat(s string) (int, float64, bool) {
n := 0
if len(s) > 0 && s[0] == '-' {
n++
}
for ; n < len(s); n++ {
if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') {
break
}
}
f, err := strconv.ParseFloat(s[:n], 64)
return n, f, err == nil
}
const funcs = `
func argb(c uint32) color.NRGBA {
return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}
func rect(p *clip.Path, origin, size f32.Point) {
p.MoveTo(origin)
p.LineTo(origin.Add(f32.Pt(size.X, 0)))
p.LineTo(origin.Add(size))
p.LineTo(origin.Add(f32.Pt(0, size.Y)))
p.Close()
}
func ellipse(p *clip.Path, center, radius f32.Point) {
r := radius.X
// We'll model the ellipse as a circle scaled in the Y
// direction.
scale := radius.Y / r
// https://pomax.github.io/bezierinfo/#circles_cubic.
const q = 4 * (math.Sqrt2 - 1) / 3
curve := r * q
top := f32.Point{X: center.X, Y: center.Y - r*scale}
p.MoveTo(top)
p.CubeTo(
f32.Point{X: center.X + curve, Y: center.Y - r*scale},
f32.Point{X: center.X + r, Y: center.Y - curve*scale},
f32.Point{X: center.X + r, Y: center.Y},
)
p.CubeTo(
f32.Point{X: center.X + r, Y: center.Y + curve*scale},
f32.Point{X: center.X + curve, Y: center.Y + r*scale},
f32.Point{X: center.X, Y: center.Y + r*scale},
)
p.CubeTo(
f32.Point{X: center.X - curve, Y: center.Y + r*scale},
f32.Point{X: center.X - r, Y: center.Y + curve*scale},
f32.Point{X: center.X - r, Y: center.Y},
)
p.CubeTo(
f32.Point{X: center.X - r, Y: center.Y - curve*scale},
f32.Point{X: center.X - curve, Y: center.Y - r*scale},
top,
)
}
`
+2 -25
View File
@@ -19,49 +19,26 @@ type focusAppearance struct {
MinHeight int MinHeight int
} }
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance { func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{ appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0}, OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))), OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)), MinHeight: metric.Dp(unit.Dp(44)),
} }
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused { if focused {
appearance.BorderColor = accentColor appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
} }
return appearance return appearance
} }
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) { func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused { if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
} }
return background, text return background, text
} }
+151 -664
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"image/color"
"strings" "strings"
"time" "time"
@@ -28,130 +27,6 @@ func apiOperations() []apitokens.Operation {
} }
} }
type apiAuditQuickFilter struct {
Label string
Query string
}
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func apiAuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func compactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func apiAuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
apiAuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
apiAuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
if len(filters) == 0 {
*clicks = nil
return nil
}
if len(*clicks) < len(filters) {
next := make([]widget.Clickable, len(filters))
copy(next, *clicks)
*clicks = next
}
return (*clicks)[:len(filters)]
}
func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilter, []apiAuditQuickFilter, []apiAuditQuickFilter) {
tokenSeen := map[string]struct{}{}
decisionSeen := map[apiaudit.EventType]struct{}{}
operationSeen := map[apitokens.Operation]struct{}{}
var tokens []apiAuditQuickFilter
var decisions []apiAuditQuickFilter
var operations []apiAuditQuickFilter
for _, event := range events {
if name := strings.TrimSpace(event.TokenName); name != "" {
if _, ok := tokenSeen[name]; !ok {
tokenSeen[name] = struct{}{}
tokens = append(tokens, apiAuditQuickFilter{Label: name, Query: name})
}
}
if _, ok := decisionSeen[event.Type]; !ok {
decisionSeen[event.Type] = struct{}{}
label := apiAuditDecisionLabel(event.Type)
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
}
if strings.TrimSpace(string(event.Operation)) == "" {
continue
}
if _, ok := operationSeen[event.Operation]; ok {
continue
}
operationSeen[event.Operation] = struct{}{}
label := apiAuditOperationLabel(event.Operation)
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
}
if len(tokens) > 4 {
tokens = tokens[:4]
}
if len(decisions) > 5 {
decisions = decisions[:5]
}
if len(operations) > 4 {
operations = operations[:4]
}
return tokens, decisions, operations
}
func (u *ui) apiTokens() []apitokens.Token { func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens() tokens, err := u.state.APITokens()
if err != nil { if err != nil {
@@ -369,57 +244,6 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil return nil
} }
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return "No group selected"
}
return strings.Join(path, " / ")
}
func (u *ui) apiPolicyEntrySummary() string {
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
if id == "" {
return "No entry selected"
}
if item, ok := u.selectedEntry(); ok && item.ID == id {
if strings.TrimSpace(item.Title) != "" {
return item.Title + " (" + id + ")"
}
}
return id
}
func (u *ui) useCurrentGroupForPolicyAction() error {
u.syncCurrentPath()
path := u.displayPath()
if len(path) == 0 {
return fmt.Errorf("navigate to a group first")
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) useSelectedEntryForPolicyAction() error {
item, ok := u.selectedEntry()
if !ok || strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("select an entry first")
}
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(item.ID)
return nil
}
func (u *ui) clearAPIPolicyTargetAction() error {
u.apiPolicyPath.SetText("")
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error { func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
@@ -450,7 +274,15 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
} }
filtered := make([]apiaudit.Event, 0, len(events)) filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events { for _, event := range events {
haystack := apiAuditEventSearchTerms(event) haystack := strings.ToLower(strings.Join([]string{
string(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}, " "))
if strings.Contains(haystack, query) { if strings.Contains(haystack, query) {
filtered = append(filtered, event) filtered = append(filtered, event)
} }
@@ -461,58 +293,16 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
return filtered return filtered
} }
func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) { func formatAPIPolicyRule(rule apitokens.PolicyRule) string {
effect := strings.ToUpper(string(rule.Effect)) scope := strings.Join(rule.Resource.Path, " / ")
operation := string(rule.Operation)
resource := "Vault root"
if rule.Resource.Kind == apitokens.ResourceEntry { if rule.Resource.Kind == apitokens.ResourceEntry {
resource = "Entry: " + rule.Resource.EntryID scope = "entry " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ")
} }
return effect, operation, resource return strings.TrimSpace(strings.Join([]string{
} strings.ToUpper(string(rule.Effect)),
string(rule.Operation),
func apiTokenStatusSummary(token apitokens.Token) string { scope,
parts := []string{"Active"} }, " "))
if token.Disabled {
parts[0] = "Disabled"
}
if token.RevokedAt != nil {
parts[0] = "Revoked"
}
if token.ExpiresAt != nil {
parts = append(parts, "Expires "+token.ExpiresAt.Local().Format(time.RFC3339))
} else {
parts = append(parts, "No expiration")
}
if len(token.Policies) == 1 {
parts = append(parts, "1 policy rule")
} else {
parts = append(parts, fmt.Sprintf("%d policy rules", len(token.Policies)))
}
return strings.Join(parts, " · ")
}
func apiTokenManagementTitle(token apitokens.Token, ok bool) string {
if !ok {
return "Issue API Token"
}
if strings.TrimSpace(token.Name) != "" {
return token.Name
}
return "Unnamed API Token"
}
func apiTokenManagementSubtitle(token apitokens.Token, ok bool) string {
if !ok {
return "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules."
}
client := strings.TrimSpace(token.ClientName)
if client == "" {
client = "No client name"
}
return client + " · " + apiTokenStatusSummary(token)
} }
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool { func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
@@ -546,21 +336,17 @@ func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, t
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName) lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), apiTokenStatusSummary(token)) text := "Non-expiring"
lbl.Color = mutedColor if token.ExpiresAt != nil {
return lbl.Layout(gtx) text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339)
}), }
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), lbl := material.Label(u.theme, unit.Sp(12), text)
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "ID "+token.ID)
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -606,11 +392,6 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), string(event.Operation))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339)) lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
lbl.Color = mutedColor lbl.Color = mutedColor
@@ -634,46 +415,11 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
tokens := u.apiTokens() tokens := u.apiTokens()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
selected, ok := u.selectedAPIToken()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Token Directory")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Grant scoped gRPC access to external tools. Search matches token name, client, token id, and policy details.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
title := fmt.Sprintf("%d tokens managed", len(tokens))
if ok {
title = "Selected: " + apiTokenManagementTitle(selected, true)
}
lbl := material.Label(u.theme, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !ok {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), apiTokenManagementSubtitle(selected, true))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(tokens) == 0 { if len(tokens) == 0 {
return emptyStatePanel(gtx, u.theme, u.listEmptyState()) lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
} }
return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions { return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i]) return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
@@ -684,11 +430,6 @@ func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents() events := u.apiAuditEvents()
allEvents := []apiaudit.Event(nil)
if u.auditLog != nil {
allEvents = u.auditLog.Events()
}
tokenFilters, decisionFilters, operationFilters := u.apiAuditQuickFilters(allEvents)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Local gRPC audit history" text := "Local gRPC audit history"
@@ -700,19 +441,9 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Filter by token, decision, or operation. Use the quick filters below or type a resource path in Search vault.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterPanel(gtx, tokenFilters, decisionFilters, operationFilters)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 { if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), u.listEmptyMessage()) lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
} }
@@ -723,109 +454,6 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) apiAuditQuickFilterPanel(gtx layout.Context, tokenFilters, decisionFilters, operationFilters []apiAuditQuickFilter) layout.Dimensions {
hasTokens := len(tokenFilters) > 0
hasDecisions := len(decisionFilters) > 0
hasOperations := len(operationFilters) > 0
query := strings.TrimSpace(u.search.Text())
if !hasTokens && !hasDecisions && !hasOperations && query == "" {
return layout.Dimensions{}
}
children := make([]layout.FlexChild, 0, 8)
if query != "" {
children = append(children,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, &u.clearAPIAuditFilters, "Clear filters", false, "")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}
if hasTokens {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Tokens", tokenFilters, &u.apiAuditTokenFilters)
}))
}
if hasDecisions {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Decisions", decisionFilters, &u.apiAuditDecisionFilters)
}))
}
if hasOperations {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Operations", operationFilters, &u.apiAuditOperationFilters)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []apiAuditQuickFilter, clicks *[]widget.Clickable) layout.Dimensions {
buttons := apiAuditFilterButtons(clicks, filters)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
column := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
column = append(column, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
}
flexChildren := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
flexChildren = append(flexChildren, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
}),
)
}
func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable, label string, selected bool, query string) layout.Dimensions {
for click.Clicked(gtx) {
u.search.SetText(strings.TrimSpace(query))
u.filter()
}
btn := material.Button(u.theme, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
if selected {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 248, B: 238, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 224, B: 214, A: 255}
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0) removeClicks := u.ensureAPIPolicyRemoveClickables(0)
@@ -834,269 +462,140 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
} }
rows := []layout.Widget{ rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(20), "API Token")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, lbl.Color = accentColor
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx)
lbl := material.Label(u.theme, unit.Sp(20), "API Token Management")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), apiTokenManagementTitle(token, ok))
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), apiTokenManagementSubtitle(token, ok))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}, },
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditor(u.theme, "Name", &u.apiTokenName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
},
}
if ok {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token ID", token.ID),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)),
)
if token.RevokedAt != nil {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)),
)
}
}
if strings.TrimSpace(u.apiTokenSecret) != "" {
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret")
}),
)
})
},
)
} }
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout, layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
content := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveAPIToken, "Save Token")
lbl := material.Label(u.theme, unit.Sp(14), "Identity") }),
lbl.Color = accentColor layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
return lbl.Layout(gtx) layout.Rigid(func(gtx layout.Context) layout.Dimensions {
}), return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate")
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), }),
layout.Rigid(labeledEditor(u.theme, "Name", &u.apiTokenName, false)), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false)), return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable")
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), }),
layout.Rigid(labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false)), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke")
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx) }),
}), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
} layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if ok { return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete")
content = append(content, }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), )
layout.Rigid(detailLine(u.theme, "Token ID", token.ID)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339))),
)
if token.RevokedAt != nil {
content = append(content,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339))),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, content...)
})
}, },
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
) )
if ok { if ok && len(token.Policies) > 0 {
rows = append(rows, for i, rule := range token.Policies {
layout.Spacer{Height: unit.Dp(10)}.Layout, index := i
func(gtx layout.Context) layout.Dimensions { ruleText := formatAPIPolicyRule(rule)
return card(gtx, func(gtx layout.Context) layout.Dimensions { rows = append(rows,
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
lbl := material.Label(u.theme, unit.Sp(14), "Lifecycle") layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl := material.Label(u.theme, unit.Sp(13), ruleText)
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Save updates, rotate secret material, or shut a token down without leaving this surface.")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
layout.Rigid(func(gtx layout.Context) layout.Dimensions { }),
return tonedButton(gtx, u.theme, &u.saveAPIToken, "Save Token") )
}), },
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Spacer{Height: unit.Dp(6)}.Layout,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { )
return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate") }
}), } else {
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), rows = append(rows, func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable") 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 layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete")
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.apiTokenSecret) == "" {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret")
}),
)
})
})
}),
)
})
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
sectionRows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Effect, operation, and resource are separated so rules scan quickly while you review token scope.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
}
if len(token.Policies) > 0 {
for i, rule := range token.Policies {
index := i
effect, operation, resource := policyRuleParts(rule)
sectionRows = append(sectionRows,
func(gtx layout.Context) 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 {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Operation", operation)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Resource", resource)),
)
})
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
)
}
} else {
sectionRows = append(sectionRows, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}
return material.List(u.theme, &u.apiPolicyList).Layout(gtx, len(sectionRows), func(gtx layout.Context, i int) layout.Dimensions {
return sectionRows[i](gtx)
})
})
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
)
} }
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, },
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: unit.Dp(6)}.Layout,
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer") func(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
return lbl.Layout(gtx) },
}), layout.Spacer{Height: unit.Dp(6)}.Layout,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Spacer{Height: unit.Dp(6)}.Layout,
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.") labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false),
lbl.Color = mutedColor layout.Spacer{Height: unit.Dp(6)}.Layout,
return lbl.Layout(gtx) labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false),
}), layout.Spacer{Height: unit.Dp(6)}.Layout,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.apiPolicyGroupScopeW.Value {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useCurrentGroupForPolicy, "Use Current Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSelectedEntryForPolicy, "Use Selected Entry")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
}),
)
})
}, },
) )
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
@@ -1127,28 +626,16 @@ func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
event := events[u.selectedAuditIndex] event := events[u.selectedAuditIndex]
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(12)}.Layout, layout.Spacer{Height: unit.Dp(12)}.Layout,
func(gtx layout.Context) layout.Dimensions { detailLine(u.theme, "Type", string(event.Type)),
lbl := material.Label(u.theme, unit.Sp(12), "Selected event")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Type", string(event.Type))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Operation", string(event.Operation))),
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)), detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token", event.TokenName), detailLine(u.theme, "Token", event.TokenName),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Client", event.ClientName), detailLine(u.theme, "Client", event.ClientName),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Operation", string(event.Operation)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Resource", formatAuditResource(event.Resource)), detailLine(u.theme, "Resource", formatAuditResource(event.Resource)),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Message", event.Message), detailLine(u.theme, "Message", event.Message),
-36
View File
@@ -41,7 +41,6 @@ func (u *ui) attachmentInput() (string, []byte, error) {
func (u *ui) loadSelectedEntryIntoEditor() { func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek() u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1 u.selectedHistoryIndex = -1
u.historyIndex.SetText("") u.historyIndex.SetText("")
@@ -176,7 +175,6 @@ func (u *ui) saveEntryAction() error {
return err return err
} }
u.editingEntry = false u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter() u.filter()
return nil return nil
} }
@@ -231,7 +229,6 @@ func (u *ui) saveTemplateAction() error {
return err return err
} }
u.editingEntry = false u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter() u.filter()
return nil return nil
} }
@@ -254,14 +251,6 @@ func (u *ui) createGroupAction() error {
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text())) return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
} }
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error { func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation() u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text())) return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
@@ -411,7 +400,6 @@ func (u *ui) generatePasswordAction() error {
return err return err
} }
u.entryPassword.SetText(password) u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil return nil
} }
@@ -493,27 +481,3 @@ func marshalFields(fields map[string]string) string {
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func (u *ui) clearGeneratedPasswordDraft() {
u.generatedPasswordDraft = false
}
func (u *ui) attachmentActionSummary() string {
items := u.selectedAttachmentItems()
if len(items) == 0 {
return "No attachments yet. Add one below to store a file with this entry."
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "Select an attachment above, then replace it, export it, or remove it below."
}
for _, item := range items {
if item.Name == name {
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
}
}
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
}
+258 -1159
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
return true return true
} }
if u.isVaultLocked() && name == key.NameReturn { if u.isVaultLocked() && name == key.NameReturn {
u.startUnlockAction() u.runAction("unlock vault", u.unlockAction)
return true return true
} }
if u.shouldShowLifecycleSetup() && name == key.NameReturn { if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
u.startOpenRemoteAction() u.runAction("open remote vault", u.openRemoteAction)
} else { } else {
u.startOpenVaultAction() u.runAction("open vault", u.openVaultAction)
} }
return true return true
} }
-300
View File
@@ -1,300 +0,0 @@
package main
import (
"encoding/json"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/vault"
)
const (
displayDensityDense = "dense"
displayDensityComfortable = "comfortable"
contrastStandard = "standard"
contrastHigh = "high"
keyboardFocusStandard = "standard"
keyboardFocusProminent = "prominent"
)
type accessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(true),
Contrast: contrastStandard,
KeyboardFocus: keyboardFocusStandard,
}
}
func displayDensityForDenseLayout(dense bool) string {
if dense {
return displayDensityDense
}
return displayDensityComfortable
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
normalized := defaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case displayDensityDense, displayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case contrastStandard, contrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case keyboardFocusStandard, keyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
},
}
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
-5
View File
@@ -37,8 +37,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
key.Filter{Name: key.NameUpArrow}, key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow}, key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn}, key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
) )
if !ok { if !ok {
break break
@@ -50,9 +48,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
} }
u.handleKeyPress(ke.Name, ke.Modifiers) u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
} }
} }
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "old-token", Password: "old-token",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "Original note", Notes: "Original note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
} }
model.UpsertEntry(Entry{ model.UpsertEntry(Entry{
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "new-token", Password: "new-token",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "Updated note", Notes: "Updated note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "surveillance-console", ID: "ha-codex",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
} }
if err := model.DeleteEntry("surveillance-console"); err != nil { if err := model.DeleteEntry("ha-codex"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
} }
if model.RecycleBin[0].Title != "Surveillance Console" { if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console") t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
} }
} }
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{ model := Model{
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "bellagio", ID: "dynadot",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "token-3", Password: "token-3",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
} }
if err := model.RestoreEntry("bellagio"); err != nil { if err := model.RestoreEntry("dynadot"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
} }
if got[0].Title != "Bellagio" { if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio") t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
} }
if len(model.RecycleBin) != 0 { if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "new-token", Password: "new-token",
Notes: "Current note", Notes: "Current note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "vault-console-history-1", ID: "git-server-history-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "old-token", Password: "old-token",
Notes: "Previous note", Notes: "Previous note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
}, },
} }
if err := model.RestoreEntryVersion("vault-console", 0); err != nil { if err := model.RestoreEntryVersion("git-server", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err) t.Fatalf("RestoreEntryVersion() error = %v", err)
} }
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Root",
mustGroup("Internet", mustGroup("Internet",
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"), mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"), mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
), ),
mustGroup("Home Assistant", mustGroup("Home Assistant",
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"), mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
), ),
), ),
}, },
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" { if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
t.Fatalf("unexpected first entry: %#v", got[0]) t.Fatalf("unexpected first entry: %#v", got[0])
} }
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" { if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
t.Fatalf("unexpected second entry: %#v", got[1]) t.Fatalf("unexpected second entry: %#v", got[1])
} }
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) { func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel() t.Parallel()
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2") entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
entry.Tags = "automation; home" entry.Tags = "automation; home"
entry.Values = append(entry.Values, entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "Personal git server token entry used for automation and CLI auth.", Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"}, Tags: []string{"git", "infra"},
Fields: map[string]string{ Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Notes: "Long-lived token used by Codex for home automation tasks.", Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"}, Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err) t.Fatalf("LoadKDBX() error = %v", err)
} }
got := loaded.Search("vault") got := loaded.Search("git")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got)) t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
} }
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "new-token", Password: "new-token",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-old", ID: "entry-1-old",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "old-token", Password: "old-token",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Notes: "Original version", Notes: "Original version",
}, },
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
} }
if loaded.RecycleBin[0].Title != "Surveillance Console" { if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console") t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
} }
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
}, },
}, },
}, },
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := model.Search("vault") got := model.Search("git")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got) t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
} }
} }
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))), mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
}, },
}, },
}, },
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
}, },
}, },
}, },
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.Search("vault") got := loaded.Search("git")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got) t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
} }
} }
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "surveillance-console", ID: "ha-codex",
Title: "Surveillance Console", Title: "Home Assistant (Codex)",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://surveillance.crew.example.invalid", URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "vault-console", ID: "git-server",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-2", Password: "token-2",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "Current credential", Notes: "Current credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
URL: "https://vault.crew.example.invalid", URL: "https://git.julianfamily.org",
Notes: "Original credential", Notes: "Original credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
+5 -89
View File
@@ -96,27 +96,6 @@ func (m Model) EntriesInPath(path []string) []Entry {
return entries return entries
} }
func (m Model) EntriesUnderPath(path []string) []Entry {
var entries []Entry
for _, entry := range m.Entries {
if !hasPathPrefix(entry.Path, path) {
continue
}
entries = append(entries, entry)
}
slices.SortFunc(entries, func(a, b Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return entries
}
func (m Model) Search(query string) []SearchResult { func (m Model) Search(query string) []SearchResult {
query = strings.TrimSpace(strings.ToLower(query)) query = strings.TrimSpace(strings.ToLower(query))
if query == "" { if query == "" {
@@ -277,14 +256,13 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
} }
func (m *Model) CreateGroup(parent []string, name string) { func (m *Model) CreateGroup(parent []string, name string) {
groupPath := append([]string(nil), parent...) groupPath := append(append([]string(nil), parent...), name)
for _, part := range splitGroupPath(name) { for _, existing := range m.Groups {
groupPath = append(groupPath, part) if slices.Equal(existing, groupPath) {
if groupPathExists(m.Groups, groupPath) { return
continue
} }
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
} }
m.Groups = append(m.Groups, groupPath)
} }
func (m *Model) RenameGroup(path []string, newName string) error { func (m *Model) RenameGroup(path []string, newName string) error {
@@ -332,47 +310,6 @@ func (m *Model) MoveEntry(id string, path []string) error {
return ErrEntryNotFound return ErrEntryNotFound
} }
func (m *Model) MoveGroup(path, parent []string) error {
if len(path) == 0 {
return ErrEntryNotFound
}
if hasPathPrefix(parent, path) {
return ErrEntryNotFound
}
groupName := path[len(path)-1]
newPath := append(append([]string(nil), parent...), groupName)
moved := false
for i := range m.Entries {
if !hasPathPrefix(m.Entries[i].Path, path) {
continue
}
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
moved = true
}
for i := range m.Templates {
if !hasPathPrefix(m.Templates[i].Path, path) {
continue
}
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
moved = true
}
for i := range m.Groups {
if !hasPathPrefix(m.Groups[i], path) {
continue
}
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
moved = true
}
if !moved {
return ErrEntryNotFound
}
if !groupPathExists(m.Groups, newPath) {
m.Groups = append(m.Groups, append([]string(nil), newPath...))
}
return nil
}
func (m *Model) MoveTemplate(id string, path []string) error { func (m *Model) MoveTemplate(id string, path []string) error {
for i := range m.Templates { for i := range m.Templates {
if m.Templates[i].ID != id { if m.Templates[i].ID != id {
@@ -412,27 +349,6 @@ func hasPathPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func splitGroupPath(name string) []string {
var parts []string
for _, part := range strings.Split(name, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
parts = append(parts, part)
}
return parts
}
func groupPathExists(groups [][]string, path []string) bool {
for _, existing := range groups {
if slices.Equal(existing, path) {
return true
}
}
return false
}
func mergeEntryTemplate(template, overrides Entry) Entry { func mergeEntryTemplate(template, overrides Entry) Entry {
entry := cloneEntry(template) entry := cloneEntry(template)
+36 -87
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model { func testModel() Model {
return Model{ return Model{
Entries: []Entry{ Entries: []Entry{
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, {ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, {ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}}, {ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
}, },
} }
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) { func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel() model := testModel()
got := model.ChildGroups([]string{"Crew"}) got := model.ChildGroups([]string{"Joe"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
@@ -31,43 +31,30 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel() model := testModel()
got := model.EntriesInPath([]string{"Crew", "Internet"}) got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" { if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title) t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
} }
} }
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel() model := testModel()
got := model.Search("vault") got := model.Search("git")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got)) t.Fatalf("len(Search()) = %d, want 1", len(got))
} }
if got[0].Entry.Title != "Vault Console" { if got[0].Entry.Title != "Git Server" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console") t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
} }
if got[0].Path != "Crew / Internet" { if got[0].Path != "Joe / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet") t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
} }
} }
@@ -119,11 +106,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{ entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1", ID: "entry-1",
Title: "Bellagio", Title: "Dynadot",
Username: "rustyryan", Username: "jjulian",
Password: "hunter2", Password: "hunter2",
URL: "https://bellagio.example.invalid", URL: "https://www.dynadot.com",
Path: []string{"Crew", "Internet"}, Path: []string{"Joe", "Internet"},
Tags: []string{"dns"}, Tags: []string{"dns"},
}) })
if err != nil { if err != nil {
@@ -134,11 +121,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1") t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
} }
if entry.Title != "Bellagio" { if entry.Title != "Dynadot" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio") t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
} }
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" { if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
t.Fatalf("entry credentials = %#v, want override values", entry) t.Fatalf("entry credentials = %#v, want override values", entry)
} }
@@ -154,9 +141,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod") t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
} }
got := model.EntriesInPath([]string{"Crew", "Internet"}) got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 1 || got[0].Title != "Bellagio" { if len(got) != 1 || got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got) t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
} }
} }
@@ -178,7 +165,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
}, },
Templates: []Entry{ Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -222,8 +209,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Vault Console", Title: "Git Server",
Username: "dannyocean", Username: "joejulian",
Password: "token-1", Password: "token-1",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -238,8 +225,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" { if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
} }
if duplicate.Title != "Vault Console (Copy)" { if duplicate.Title != "Git Server (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)") t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
} }
got := model.EntriesInPath([]string{"Root", "Internet"}) got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 { if len(got) != 2 {
@@ -250,45 +237,29 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance") model.CreateGroup([]string{"Joe"}, "Finance")
got := model.ChildGroups([]string{"Crew"}) got := model.ChildGroups([]string{"Joe"})
want := []string{"Finance", "Home Assistant", "Internet"} want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
} }
} }
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
}
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel() model := testModel()
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil { if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err) t.Fatalf("RenameGroup() error = %v", err)
} }
got := model.EntriesInPath([]string{"Crew", "Infra"}) got := model.EntriesInPath([]string{"Joe", "Infra"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename") t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
} }
} }
@@ -305,37 +276,15 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
} }
} }
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Tricia", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
}
}
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance") model.CreateGroup([]string{"Joe"}, "Finance")
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil { if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err) t.Fatalf("DeleteGroup() error = %v", err)
} }
got := model.ChildGroups([]string{"Crew"}) got := model.ChildGroups([]string{"Joe"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method) t.Fatalf("method = %s, want GET", r.Method)
} }
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" { if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok) t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
} }
w.Header().Set("ETag", `"etag-1"`) w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes") _, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "rustyryan", Username: "jjulian",
Password: "secret", Password: "secret",
} }
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "rustyryan", Username: "jjulian",
Password: "secret", Password: "secret",
} }
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "rustyryan", Username: "jjulian",
Password: "secret", Password: "secret",
} }