Compare commits

..

2 Commits

Author SHA1 Message Date
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
107 changed files with 1383 additions and 29444 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"
+1 -2
View File
@@ -1,2 +1 @@
build/
*.apk
gio-keepass-mock
-21
View File
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are:
`go-code-review`,
`go-concurrency`,
@@ -127,23 +125,4 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the 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.
- 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.
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
- Do not commit generated binaries.
-48
View File
@@ -1,48 +0,0 @@
# Android Build
Build the APK with:
```sh
make apk
```
Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
- `ANDROID_TARGET_SDK` overrides the target Android SDK.
Installed machine prerequisites expected by this repo:
- `android-sdk-cmdline-tools-latest`
- `android-sdk-build-tools`
- `android-platform-35`
- `android-sdk-platform-tools`
- a working JDK install
The repo tracks `gogio` as a Go tool, so the build runs through:
```sh
go tool gogio -target android ...
```
The Android build uses the branded icon asset at:
- `assets/keepassgo-icon.png`
Note:
- Gio's Android doc currently references Java 1.8, but the Android build-tools
installed on this machine (`d8` from build-tools 37) do not run on Java 8.
- In this environment, KeePassGO's APK build requires a newer JDK runtime on
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
pipeline. KeePassGO is pinned to the working Gio line until that regression is
understood upstream.
-44
View File
@@ -1,44 +0,0 @@
ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
.PHONY: apk
apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@test -x "$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin/sdkmanager" || { echo "Android SDK cmdline-tools are missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))"
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
JAVA_HOME="$(JAVA_HOME)" \
go tool gogio -target android \
-buildmode exe \
-appid $(APP_ID) \
-o $(APK_OUT) \
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \
-icon assets/keepassgo-icon.png \
.
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
"$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
+4 -33
View File
@@ -1,12 +1,11 @@
# KeePassGO
KeePassGO is a Go-based KeePass-compatible password manager targeting desktop first, with future Android support.
KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support.
## Current Capabilities
- KDBX load and save
- password-only, key-file-only, and composite master-key flows through the desktop product UI
- master-key changes for existing vault sessions
- password, key-file, and composite master-key support in the storage layer
- WebDAV-backed open and save support in the session layer
- password generation profiles
- gRPC integration surface for trusted automation
@@ -41,48 +40,20 @@ Desktop 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
KeePassGO uses Gio, so Android packaging is done with `gogio`.
The repo now has automated tests for the packaging contract:
- default APK build arguments
- required Android SDK / NDK / JDK layout checks
Those are covered by normal test runs:
```bash
go test ./...
```
Install:
```bash
go get -tool gioui.org/cmd/gogio@latest
go install gioui.org/cmd/gogio@latest
```
Package:
```bash
go tool gogio -target android -icon assets/keepassgo-icon.png .
gogio -target android .
```
You will need the Android SDK and NDK installed and configured for real device or release packaging.
-308
View File
@@ -6,291 +6,6 @@ These segments are intended to be independently executable wherever possible.
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.
## 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
These segments define the work for programmatic access control over gRPC.
They are designed to be independently landable wherever file overlap permits.
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
### API Segment A: Token Domain Model
Scope:
- Represent API tokens as first-class vault-backed records.
- Mark token entries explicitly as API credentials rather than generic passwords.
- Store token metadata:
token id,
hashed secret or verifier,
display name,
client name,
created at,
expires at,
disabled state.
- Keep the persisted representation compatible with KDBX entry fields.
Exit criteria:
- A domain type exists for API tokens and round-trips through the persisted vault model.
- Generic entry listing can distinguish API token entries from ordinary secrets.
- Tests cover create, load, save, and parse behavior for API token entries.
- `go test ./...` passes.
### API Segment B: Token Issuance And Rotation
Scope:
- Generate new API tokens for external tools.
- Return the cleartext token only at creation or explicit rotation time.
- Rotate an existing token while preserving its identity and policy linkage.
- Revoke or disable a token without deleting policy history.
Exit criteria:
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
- Cleartext token material is only exposed on creation or rotation paths.
- Tests cover generation, rotation, and disable/revoke semantics.
- `go test ./...` passes.
### API Segment C: Token Expiration
Scope:
- Allow tokens to have optional expiration timestamps.
- Treat expired tokens as unauthenticated.
- Surface expiration in UI and gRPC management views.
- Support non-expiring tokens explicitly.
Exit criteria:
- Expired tokens are rejected by the gRPC authentication path.
- Token expiration can be created, edited, and removed through the service layer.
- Tests cover valid, expired, and non-expiring token behavior.
- `go test ./...` passes.
### API Segment D: Authorization Policy Model
Scope:
- Define an authorization model for token-scoped access.
- Support allow and deny rules over:
folders/groups,
specific entries,
entry fields where needed,
and operation types.
- Keep specific deny rules higher priority than broad allow rules.
- Model “not yet decided” separately from “denied”.
Exit criteria:
- A policy evaluator exists for token, resource, and operation tuples.
- Explicit deny overrides allow.
- Unspecified access is distinguishable from denied access.
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
- `go test ./...` passes.
### API Segment E: gRPC Authentication And Authorization Enforcement
Scope:
- Replace the current single static bearer-token interceptor with token-backed auth.
- Authenticate callers using issued KeePassGO API tokens.
- Authorize every gRPC method against token policy.
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
Exit criteria:
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
- Unauthorized requests return the correct authz/authn gRPC status.
- Integration tests cover permitted, denied, expired, and revoked token behavior.
- `go test ./...` passes.
### API Segment F: Approval Queue And Pending Access Requests
Scope:
- When a token requests access to a resource that is neither explicitly allowed nor denied:
create a pending approval request.
- Include:
token identity,
client name,
requested operation,
requested group/entry scope,
requested time,
and permanence choice.
- Allow the request to be accepted, denied, or canceled by the user.
Exit criteria:
- Unspecified access creates a pending approval instead of silently denying or allowing.
- Pending approvals are queryable from the application layer.
- Canceling the prompt results in the API request failing without granting access.
- Tests cover pending creation, approval, denial, and cancellation.
- `go test ./...` passes.
### API Segment G: Approval UI
Scope:
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
- Provide actions:
allow once,
deny once,
allow permanently,
deny permanently,
cancel.
- Make the requested scope and operation clear to the user.
- Ensure the dialog appears only for requests not already decided.
Exit criteria:
- A pending request triggers a visible approval surface in the app.
- The user can allow, deny, or cancel from the UI.
- Permanent decisions become persisted policy rules.
- UI tests cover each approval outcome.
- `go test ./...` passes.
### API Segment H: gRPC Request Blocking And Resume Behavior
Scope:
- Define how an in-flight gRPC call waits for or fails on user approval.
- Hold the request while approval is pending within a bounded timeout.
- Return unauthenticated or permission-denied when denied/canceled/expired.
- Resume the original call automatically when approval is granted.
Exit criteria:
- Pending requests block safely without leaking goroutines.
- Allowed requests resume and complete without the client reissuing the call where practical.
- Denied and canceled requests return a consistent gRPC status code and message.
- Tests cover timeout, allow, deny, and cancel paths.
- `go test ./...` passes.
### API Segment I: Token Management UI
Scope:
- Add UI for listing API tokens.
- Create token flow with one-time secret display.
- Edit token display metadata and expiration.
- Disable, revoke, and rotate tokens.
- Show effective policy summary per token.
Exit criteria:
- Users can manage API tokens from the app UI end to end.
- One-time token display is explicit and not re-shown later.
- Expiration and disable state are visible.
- UI tests cover create, rotate, disable, revoke, and edit flows.
- `go test ./...` passes.
### API Segment J: Policy Management UI
Scope:
- Let users define folder, entry, and operation scopes for each token.
- Show explicit allow and deny rules.
- Show inherited implications of a folder-level rule.
- Let users review prior permanent decisions created from approval prompts.
Exit criteria:
- Users can inspect and edit token policy from the UI.
- Folder-level and entry-level rules are distinguishable and editable.
- Permanent prompt decisions are visible as policy.
- UI tests cover rule creation, update, and deletion.
- `go test ./...` passes.
### API Segment K: Audit And Event History
Scope:
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
- Record authorization failures and expirations without logging secret material.
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
Exit criteria:
- Security-relevant API token events are captured without secret leakage.
- Approval outcomes and policy changes are auditable.
- Tests cover audit generation for the main token lifecycle and approval actions.
- `go test ./...` passes.
### Segment 1: Application State Ownership
Scope:
@@ -545,21 +260,6 @@ Exit criteria:
- Focus and accessibility states are visible and intentional.
- `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
Scope:
@@ -641,11 +341,3 @@ Do not treat the product as complete until all of the following are true:
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes.
- `go tool golangci-lint run ./...` passes.
## Remaining Gaps Against AGENTS.md
None currently identified.
The last explicitly tracked gaps are now closed:
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
-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;
}
}
}
-122
View File
@@ -1,122 +0,0 @@
package api
import (
"errors"
"fmt"
"net"
"strings"
"sync"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
)
type DirtyProvider func() bool
type Host struct {
server *Server
grpcServer *grpc.Server
listener net.Listener
lifecycle lifecycleBackend
dirty DirtyProvider
mu sync.Mutex
lastModel vault.Model
started bool
listenAddr string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
addr = strings.TrimSpace(addr)
if addr == "" || strings.EqualFold(addr, "off") {
return nil, nil
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
}
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{
server: service,
grpcServer: server,
listener: listener,
lifecycle: lifecycle,
dirty: dirty,
listenAddr: listener.Addr().String(),
started: true,
}
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
_ = listener.Close()
server.Stop()
return nil, err
}
go func() {
_ = server.Serve(listener)
}()
return host, nil
}
func (h *Host) Address() string {
if h == nil {
return ""
}
return h.listenAddr
}
func (h *Host) Server() *Server {
if h == nil {
return nil
}
return h.server
}
func (h *Host) Stop() error {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
if !h.started {
return nil
}
h.started = false
h.grpcServer.Stop()
return h.listener.Close()
}
func (h *Host) SyncFromLifecycle() error {
if h == nil || h.lifecycle == nil || h.server == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
model, err := h.lifecycle.Current()
locked := false
switch {
case err == nil:
h.lastModel = model
case errors.Is(err, session.ErrLocked):
locked = true
default:
return err
}
dirty := false
if h.dirty != nil {
dirty = h.dirty()
}
h.server.SetSessionState(h.lastModel, locked, dirty)
return nil
}
-72
View File
@@ -1,72 +0,0 @@
package api
import (
"context"
"net"
"testing"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Parallel()
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
defer func() { _ = host.Stop() }()
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", host.Address())
}),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
defer func() { _ = conn.Close() }()
client := keepassgov1.NewVaultServiceClient(conn)
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
if !statusResp.Dirty {
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
}
if err := lifecycle.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if err := host.SyncFromLifecycle(); err != nil {
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
}
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
}
}
+103 -456
View File
@@ -4,21 +4,15 @@ import (
"context"
"errors"
"maps"
"os"
"slices"
"strings"
"sync"
"time"
"strings"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
@@ -35,8 +29,6 @@ type Server struct {
lifecycle lifecycleBackend
profiles map[string]passwords.Profile
clipboard clipboard.Writer
approvals *apiapproval.Broker
audit *apiaudit.Log
}
type lifecycleBackend interface {
@@ -44,13 +36,6 @@ type lifecycleBackend interface {
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
Lock() error
Unlock(vault.MasterKey) error
}
type modelReplaceableLifecycle interface {
lifecycleBackend
Replace(vault.Model)
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
@@ -58,8 +43,6 @@ func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboa
model: model,
profiles: profiles,
clipboard: clipboardWriter,
approvals: apiapproval.NewBroker(30 * time.Second),
audit: apiaudit.New(200),
}
}
@@ -69,33 +52,13 @@ func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Pro
return server
}
func (s *Server) ApprovalBroker() *apiapproval.Broker {
return s.approvals
}
func (s *Server) AuditLog() *apiaudit.Log {
return s.audit
}
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
return s.approvals.Resolve(id, outcome)
}
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
s.locked = locked
s.dirty = dirty
}
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
@@ -107,12 +70,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, mapLifecycleError("open vault", err)
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load opened vault", err)
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
}
s.mu.Lock()
@@ -136,12 +99,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, mapLifecycleError("open remote vault", err)
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load opened remote vault", err)
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
}
s.mu.Lock()
@@ -159,7 +122,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
if err := s.lifecycle.Save(); err != nil {
return nil, mapLifecycleError("save vault", err)
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
}
s.mu.Lock()
@@ -170,78 +133,40 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Lock(); err != nil {
return nil, mapLifecycleError("lock vault", err)
}
s.mu.Lock()
defer s.mu.Unlock()
s.locked = true
s.mu.Unlock()
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Unlock(key); err != nil {
return nil, mapLifecycleError("unlock vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load unlocked vault", err)
}
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
s.mu.Lock()
s.model = model
defer s.mu.Unlock()
s.locked = false
s.mu.Unlock()
return &keepassgov1.UnlockVaultResponse{}, nil
}
func mapLifecycleError(operation string, err error) error {
switch {
case errors.Is(err, os.ErrNotExist):
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
case errors.Is(err, vault.ErrInvalidMasterKey):
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
case errors.Is(err, webdav.ErrConflict):
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
default:
return status.Errorf(codes.Internal, "%s: %v", operation, err)
}
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
model, locked := s.snapshotModel()
if locked {
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
return nil, err
}
model = visibleModel(model)
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := model.Search(req.GetQuery())
results := s.model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
}
} else {
entries = model.EntriesInPath(req.GetPath())
entries = s.model.EntriesInPath(req.GetPath())
}
resp := &keepassgov1.ListEntriesResponse{
@@ -254,27 +179,23 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
return resp, nil
}
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
model, locked := s.snapshotModel()
if locked {
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
return nil, err
}
return &keepassgov1.ListGroupsResponse{
Names: visibleModel(model).ChildGroups(req.GetPath()),
Names: s.model.ChildGroups(req.GetPath()),
}, nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
return nil, err
}
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -284,13 +205,10 @@ func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRe
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
@@ -306,41 +224,12 @@ func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRe
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
if s.locked {
@@ -354,20 +243,14 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if entry, err := findEntryByID(model, req.GetId()); err == nil {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
}
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
@@ -379,27 +262,21 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
return &keepassgov1.DeleteEntryResponse{}, nil
}
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var restored vault.Entry
for _, entry := range model.RecycleBin {
for _, entry := range s.model.RecycleBin {
if entry.ID == req.GetId() {
restored = entry
break
}
}
if restored.ID != "" {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RestoreEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
@@ -412,19 +289,18 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
}
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
resp := &keepassgov1.ListEntryHistoryResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
@@ -435,21 +311,14 @@ func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntr
return resp, nil
}
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
@@ -457,7 +326,7 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
}
entry, err = findEntryByID(s.model, req.GetId())
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
@@ -545,19 +414,18 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
model, locked := s.snapshotModel()
if locked {
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
names := make([]string, 0, len(entry.Attachments))
for name := range entry.Attachments {
@@ -568,21 +436,14 @@ func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttac
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
}
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
@@ -598,19 +459,18 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt
return &keepassgov1.UploadAttachmentResponse{}, nil
}
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
content, ok := entry.Attachments[req.GetName()]
if !ok {
@@ -622,21 +482,14 @@ func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.Downlo
}, nil
}
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
@@ -656,18 +509,15 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
model, locked := s.snapshotModel()
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
s.mu.RLock()
model := s.model
locked := s.locked
s.mu.RUnlock()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil {
return nil, err
}
service := clipboard.Service{Writer: s.clipboard}
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
@@ -684,21 +534,17 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
return &keepassgov1.CopyEntryFieldResponse{}, nil
}
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.authenticateRequest(ctx); err != nil {
return nil, err
}
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
profile, ok := s.profiles[req.GetProfile()]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
}
password, err := passwords.Generate(profile)
@@ -719,7 +565,6 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Fields: maps.Clone(entry.Fields),
}
}
@@ -733,7 +578,6 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: maps.Clone(entry.GetFields()),
}
}
@@ -756,224 +600,27 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func visibleModel(model vault.Model) vault.Model {
out := model
out.Entries = nil
for _, entry := range model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err == nil && ok && token.ID != "" {
continue
}
out.Entries = append(out.Entries, entry)
}
out.Groups = nil
for _, path := range model.Groups {
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
continue
}
out.Groups = append(out.Groups, path)
}
return out
}
func (s *Server) snapshotModel() (vault.Model, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.model, s.locked
}
var timeNow = func() time.Time { return time.Now().UTC() }
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
}
const prefix = "Bearer "
if !strings.HasPrefix(values[0], prefix) {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
}
s.mu.RLock()
tokens, err := apitokens.Entries(s.model)
s.mu.RUnlock()
if err != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err)
}
token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow())
if err != nil {
switch err {
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
}
}
return token, nil
}
func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
}
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
}
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) {
case apitokens.DecisionAllow:
return token, nil
case apitokens.DecisionDeny:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
case apitokens.DecisionPrompt:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalRequested,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
result, err := s.approvals.Request(ctx, token, op, resource)
if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
}
}
switch {
case err == nil:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalAllowed,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalDenied,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalCanceled,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalTimedOut,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled):
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
case errors.Is(err, context.DeadlineExceeded):
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
}
default:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
}
}
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, entry := range s.model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err != nil || !ok || token.ID != tokenID {
continue
}
if !hasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
s.model.Entries[i] = token.Entry(entry.Path)
s.dirty = true
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
return nil
}
return status.Error(codes.NotFound, "api token entry not found")
}
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
return true
}
}
return false
}
func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) {
case clipboard.TargetUsername:
return apitokens.OperationCopyUsername
case clipboard.TargetURL:
return apitokens.OperationCopyURL
default:
return apitokens.OperationCopyPassword
}
}
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
switch info.FullMethod {
case "/keepassgo.v1.VaultService/GetSessionStatus",
"/keepassgo.v1.VaultService/OpenVault",
"/keepassgo.v1.VaultService/OpenRemoteVault",
"/keepassgo.v1.VaultService/SaveVault",
"/keepassgo.v1.VaultService/LockVault",
"/keepassgo.v1.VaultService/UnlockVault":
if _, err := server.authenticateRequest(ctx); err != nil {
return nil, err
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization")
}
if values[0] != "Bearer "+expectedToken {
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
}
return handler(ctx, req)
}
}
+83 -750
View File
File diff suppressed because it is too large Load Diff
-215
View File
@@ -1,215 +0,0 @@
package apiapproval
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
)
type Outcome string
const (
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
)
type Request struct {
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
RequestedAt time.Time
}
type Result struct {
Outcome Outcome
Rule *apitokens.PolicyRule
}
type Broker struct {
mu sync.Mutex
pending map[string]*pendingRequest
timeout time.Duration
now func() time.Time
nextID func() string
}
type pendingRequest struct {
request Request
done chan Outcome
}
type idGenerator struct {
mu sync.Mutex
counter int
}
func NewBroker(timeout time.Duration) *Broker {
gen := &idGenerator{}
return &Broker{
pending: map[string]*pendingRequest{},
timeout: timeout,
now: func() time.Time {
return time.Now().UTC()
},
nextID: func() string {
return gen.Next()
},
}
}
func (g *idGenerator) Next() string {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
return "approval-" + strconv.Itoa(g.counter)
}
func (b *Broker) Pending() []Request {
b.mu.Lock()
defer b.mu.Unlock()
requests := make([]Request, 0, len(b.pending))
for _, pending := range b.pending {
requests = append(requests, pending.request)
}
slices.SortFunc(requests, func(a, c Request) int {
switch {
case a.RequestedAt.Before(c.RequestedAt):
return -1
case a.RequestedAt.After(c.RequestedAt):
return 1
case a.ID < c.ID:
return -1
case a.ID > c.ID:
return 1
default:
return 0
}
})
return requests
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrRequestTimedOut
}
pending := &pendingRequest{
request: Request{
ID: b.nextID(),
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
RequestedAt: b.now(),
},
done: make(chan Outcome, 1),
}
b.mu.Lock()
b.pending[pending.request.ID] = pending
b.mu.Unlock()
defer func() {
b.mu.Lock()
delete(b.pending, pending.request.ID)
b.mu.Unlock()
}()
timer := time.NewTimer(b.timeout)
defer timer.Stop()
select {
case outcome := <-pending.done:
result, err := resultForOutcome(pending.request, outcome)
if err != nil {
return result, err
}
return result, nil
case <-timer.C:
return Result{}, ErrRequestTimedOut
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
b.mu.Lock()
pending, ok := b.pending[id]
b.mu.Unlock()
if !ok {
return Request{}, nil, ErrRequestNotFound
}
rule, err := RuleFromDecision(pending.request, outcome)
if err != nil {
return Request{}, nil, err
}
select {
case pending.done <- outcome:
default:
}
return pending.request, rule, nil
}
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
rule, err := RuleFromDecision(request, outcome)
if err != nil {
return Result{}, err
}
result := Result{Outcome: outcome, Rule: rule}
switch outcome {
case OutcomeAllowOnce, OutcomeAllowPermanent:
return result, nil
case OutcomeDenyOnce, OutcomeDenyPermanent:
return result, ErrRequestDenied
case OutcomeCancel:
return result, ErrRequestCanceled
default:
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
switch outcome {
case OutcomeAllowPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeDenyPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectDeny,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
return nil, nil
default:
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
-134
View File
@@ -1,134 +0,0 @@
package apiapproval
import (
"context"
"errors"
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
resultCh := make(chan Result, 1)
errCh := make(chan error, 1)
go func() {
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
resultCh <- result
errCh <- err
}()
waitForPending(t, broker, 1)
pending := broker.Pending()
if len(pending) != 1 {
t.Fatalf("Pending() len = %d, want 1", len(pending))
}
if pending[0].TokenID != "token-1" {
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
}
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
result := <-resultCh
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
if result.Outcome != OutcomeAllowOnce {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
}
if result.Rule != nil {
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
}
}
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
reqDone := make(chan struct{})
var result Result
var err error
go func() {
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
close(reqDone)
}()
waitForPending(t, broker, 1)
pending := broker.Pending()[0]
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
if resolveErr != nil {
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
}
if request.ID != pending.ID {
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
}
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
}
<-reqDone
if !errors.Is(err, ErrRequestDenied) {
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
}
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
}
if result.Outcome != OutcomeDenyPermanent {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
}
}
func TestBrokerSupportsCancellation(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
t.Fatalf("Resolve(cancel) error = %v", err)
}
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
}
}
func TestBrokerTimesOutPendingRequests(t *testing.T) {
t.Parallel()
broker := NewBroker(10 * time.Millisecond)
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrRequestTimedOut) {
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
}
}
func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := len(broker.Pending()); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("Pending() never reached len %d", want)
}
-80
View File
@@ -1,80 +0,0 @@
package apiaudit
import (
"slices"
"sync"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
type EventType string
const (
EventApprovalRequested EventType = "approval_requested"
EventApprovalAllowed EventType = "approval_allowed"
EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected"
)
type Event struct {
Type EventType
At time.Time
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
Message string
}
type Log struct {
mu sync.Mutex
max int
now func() time.Time
events []Event
}
func New(max int) *Log {
if max < 1 {
max = 1
}
return &Log{
max: max,
now: func() time.Time {
return time.Now().UTC()
},
}
}
func (l *Log) Record(event Event) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if event.At.IsZero() {
event.At = l.now()
}
l.events = append([]Event{event}, l.events...)
if len(l.events) > l.max {
l.events = l.events[:l.max]
}
}
func (l *Log) Events() []Event {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
return slices.Clone(l.events)
}
-68
View File
@@ -1,68 +0,0 @@
package apiaudit
import (
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
t.Parallel()
log := New(2)
log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"})
log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"})
log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"})
events := log.Events()
if len(events) != 2 {
t.Fatalf("len(Events()) = %d, want 2", len(events))
}
if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" {
t.Fatalf("Events() = %#v, want newest-first bounded list", events)
}
}
func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventApprovalRequested,
TokenID: "token-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}},
Message: "prompted for access",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
}
}
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
-335
View File
@@ -1,335 +0,0 @@
package apitokens
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/vault"
)
const (
EntryTypeAPIToken = "api-token"
FieldType = "KeePassGO-Type"
FieldTokenID = "KeePassGO-API-Token-ID"
FieldClientName = "KeePassGO-API-Client-Name"
FieldCreatedAt = "KeePassGO-API-Created-At"
FieldExpiresAt = "KeePassGO-API-Expires-At"
FieldDisabled = "KeePassGO-API-Disabled"
FieldRevokedAt = "KeePassGO-API-Revoked-At"
FieldSecretHash = "KeePassGO-API-Secret-Hash"
FieldPolicies = "KeePassGO-API-Policies"
)
var (
ErrNotAToken = errors.New("entry is not an api token")
ErrInvalidToken = errors.New("invalid api token")
ErrExpiredToken = errors.New("expired api token")
ErrDisabledToken = errors.New("disabled api token")
ErrTokenNotFound = errors.New("api token not found")
)
var EntryPath = []string{"Root", "API Tokens"}
type Effect string
type Operation string
type ResourceKind string
type Decision string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
ResourceGroup ResourceKind = "group"
ResourceEntry ResourceKind = "entry"
DecisionAllow Decision = "allow"
DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationManageVault Operation = "manage_vault"
)
type Resource struct {
Kind ResourceKind `json:"kind"`
Path []string `json:"path,omitempty"`
EntryID string `json:"entry_id,omitempty"`
}
type PolicyRule struct {
Effect Effect `json:"effect"`
Operation Operation `json:"operation"`
Resource Resource `json:"resource"`
}
type Token struct {
ID string
Name string
ClientName string
SecretHash string
CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
Disabled bool
Policies []PolicyRule
}
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
id, _, err := newSecret()
if err != nil {
return Token{}, "", err
}
return Token{
ID: id,
Name: strings.TrimSpace(name),
ClientName: strings.TrimSpace(clientName),
SecretHash: hashed,
CreatedAt: now.UTC(),
ExpiresAt: cloneTime(expiresAt),
}, clear, nil
}
func Rotate(token Token, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
token.SecretHash = hashed
token.Disabled = false
token.RevokedAt = nil
if token.CreatedAt.IsZero() {
token.CreatedAt = now.UTC()
}
return token, clear, nil
}
func Disable(token Token) Token {
token.Disabled = true
return token
}
func Revoke(token Token, when time.Time) Token {
token.Disabled = true
t := when.UTC()
token.RevokedAt = &t
return token
}
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
hashed := hashSecret(presentedSecret)
for _, token := range tokens {
if token.SecretHash != hashed {
continue
}
if token.Disabled || token.RevokedAt != nil {
return Token{}, ErrDisabledToken
}
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
return Token{}, ErrExpiredToken
}
return token, nil
}
return Token{}, ErrInvalidToken
}
func Evaluate(token Token, operation Operation, resource Resource) Decision {
decision := DecisionPrompt
for _, rule := range token.Policies {
if rule.Operation != operation {
continue
}
if !matches(rule.Resource, resource) {
continue
}
if rule.Effect == EffectDeny {
return DecisionDeny
}
if rule.Effect == EffectAllow {
decision = DecisionAllow
}
}
return decision
}
func Entries(model vault.Model) ([]Token, error) {
var out []Token
for _, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return nil, err
}
if ok {
out = append(out, token)
}
}
slices.SortFunc(out, func(a, b Token) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
default:
return strings.Compare(a.ID, b.ID)
}
})
return out, nil
}
func Find(model vault.Model, id string) (Token, error) {
tokens, err := Entries(model)
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if token.ID == id {
return token, nil
}
}
return Token{}, ErrTokenNotFound
}
func Upsert(model *vault.Model, token Token) {
model.UpsertEntry(token.Entry(EntryPath))
model.CreateGroup([]string{"Root"}, "API Tokens")
}
func Delete(model *vault.Model, id string) error {
for i, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return err
}
if ok && token.ID == id {
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
return nil
}
}
return ErrTokenNotFound
}
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
if entry.Fields[FieldType] != EntryTypeAPIToken {
return Token{}, false, nil
}
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
if err != nil {
return Token{}, true, fmt.Errorf("parse created at: %w", err)
}
var expiresAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
}
expiresAt = &t
}
var revokedAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
}
revokedAt = &t
}
policies := []PolicyRule{}
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
return Token{}, true, fmt.Errorf("parse policies: %w", err)
}
}
return Token{
ID: entry.Fields[FieldTokenID],
Name: entry.Title,
ClientName: entry.Fields[FieldClientName],
SecretHash: entry.Fields[FieldSecretHash],
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RevokedAt: revokedAt,
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
Policies: policies,
}, true, nil
}
func (t Token) Entry(path []string) vault.Entry {
fields := map[string]string{
FieldType: EntryTypeAPIToken,
FieldTokenID: t.ID,
FieldClientName: t.ClientName,
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
FieldSecretHash: t.SecretHash,
}
if t.ExpiresAt != nil {
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
}
if t.RevokedAt != nil {
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
}
if len(t.Policies) > 0 {
data, _ := json.Marshal(t.Policies)
fields[FieldPolicies] = string(data)
}
return vault.Entry{
ID: t.ID,
Title: t.Name,
Username: t.ClientName,
Path: slices.Clone(path),
Fields: fields,
}
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func newSecret() (string, string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate secret: %w", err)
}
clear := base64.RawURLEncoding.EncodeToString(buf)
return clear, hashSecret(clear), nil
}
func cloneTime(in *time.Time) *time.Time {
if in == nil {
return nil
}
t := in.UTC()
return &t
}
func matches(rule, resource Resource) bool {
switch rule.Kind {
case ResourceEntry:
return rule.EntryID != "" && rule.EntryID == resource.EntryID
case ResourceGroup:
if len(rule.Path) > len(resource.Path) {
return false
}
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
default:
return false
}
}
-282
View File
@@ -1,282 +0,0 @@
package apitokens
import (
"slices"
"testing"
"time"
"git.julianfamily.org/keepassgo/vault"
)
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
token := Token{
ID: "token-1",
Name: "Browser Connector",
ClientName: "browser-extension",
SecretHash: "deadbeef",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
ExpiresAt: &expiresAt,
Disabled: true,
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
},
}
entry := token.Entry([]string{"Root", "API Tokens"})
if entry.Fields[FieldType] != EntryTypeAPIToken {
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
}
got, ok, err := TokenFromEntry(entry)
if err != nil {
t.Fatalf("TokenFromEntry() error = %v", err)
}
if !ok {
t.Fatal("TokenFromEntry() ok = false, want true")
}
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
}
if len(got.Policies) != 2 {
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
}
}
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Ordinary Entry"},
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
},
}
got, err := Entries(model)
if err != nil {
t.Fatalf("Entries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(Entries()) = %d, want 2", len(got))
}
if got[0].Name != "Browser" || got[1].Name != "CLI" {
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
}
}
func TestUpsertCreatesAndUpdatesVaultTokenEntry(t *testing.T) {
t.Parallel()
model := vault.Model{}
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "grpc-cli",
SecretHash: "hash-1",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
}
Upsert(&model, token)
if len(model.Entries) != 1 {
t.Fatalf("len(model.Entries) = %d, want 1", len(model.Entries))
}
if got := model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"API Tokens"}) {
t.Fatalf("ChildGroups(Root) = %v, want [API Tokens]", got)
}
token.ClientName = "rotated-client"
token.Disabled = true
Upsert(&model, token)
got, err := Find(model, "token-1")
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if got.ClientName != "rotated-client" || !got.Disabled {
t.Fatalf("Find() = %#v, want updated client name and disabled state", got)
}
}
func TestDeleteRemovesTokenEntryByTokenID(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry(EntryPath),
{ID: "entry-1", Title: "Regular Entry"},
},
}
if err := Delete(&model, "token-1"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
t.Fatalf("model.Entries after Delete = %#v, want only regular entry", model.Entries)
}
if err := Delete(&model, "missing"); err != ErrTokenNotFound {
t.Fatalf("Delete(missing) error = %v, want %v", err, ErrTokenNotFound)
}
}
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if token.ID == "" || secret == "" {
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
}
if token.SecretHash == secret {
t.Fatal("SecretHash should not equal cleartext secret")
}
if token.Disabled {
t.Fatal("Disabled = true, want false after Issue")
}
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("Rotate() error = %v", err)
}
if rotated.ID != token.ID {
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
}
if newSecret == secret {
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
}
if rotated.SecretHash == token.SecretHash {
t.Fatal("Rotate() left SecretHash unchanged")
}
disabled := Disable(rotated)
if !disabled.Disabled {
t.Fatal("Disable() did not set Disabled")
}
revoked := Revoke(disabled, now.Add(2*time.Hour))
if !revoked.Disabled {
t.Fatal("Revoke() should leave token disabled")
}
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
}
}
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(expired) error = %v", err)
}
expiredAt := now.Add(-time.Minute)
expired.ExpiresAt = &expiredAt
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
if err != nil {
t.Fatalf("Issue(disabled) error = %v", err)
}
disabled = Disable(disabled)
tokens := []Token{expired, disabled, valid}
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
}
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
}
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
}
got, err := Authenticate(tokens, secret, now)
if err != nil {
t.Fatalf("Authenticate(valid) error = %v", err)
}
if got.ID != valid.ID {
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
}
}
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
},
}
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
if decision != DecisionAllow {
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
if decision != DecisionDeny {
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
}
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
if decision != DecisionPrompt {
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
}
}
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Policies: []PolicyRule{
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
},
}
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
if allowed != DecisionAllow || denied != DecisionDeny {
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
}
}
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
t.Parallel()
token := Token{
ID: "token-1",
Name: "CLI",
ClientName: "cli",
SecretHash: "hash",
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
Policies: []PolicyRule{
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
},
}
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
if err != nil || !ok {
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
}
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
}) {
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
}
}
+1 -407
View File
@@ -1,31 +1,20 @@
package appstate
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
type Section string
var (
ErrAttachmentAlreadyExists = errors.New("attachment already exists")
ErrAttachmentNotFound = errors.New("attachment not found")
)
const (
SectionEntries Section = ""
SectionTemplates Section = "templates"
SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
)
type CurrentSession interface {
@@ -43,30 +32,11 @@ type LockableSession interface {
Unlock(vault.MasterKey) error
}
type MasterKeyChangeableSession interface {
CurrentSession
ChangeMasterKey(vault.MasterKey) error
}
type SaveableSession interface {
CurrentSession
Save() error
}
type SynchronizableSession interface {
CurrentSession
Synchronize() error
}
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
}
type CreateableSession interface {
CurrentSession
Create(vault.Model, vault.MasterKey) error
@@ -87,208 +57,13 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings
}
type ApprovalManager interface {
Pending() []apiapproval.Request
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
}
type State struct {
Session CurrentSession
Approvals ApprovalManager
Section Section
CurrentPath []string
SearchQuery string
SelectedEntryID string
Dirty bool
StatusMessage string
ErrorMessage string
}
func (s *State) PendingApprovals() []apiapproval.Request {
if s.Approvals == nil {
return nil
}
return s.Approvals.Pending()
}
func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
if s.Approvals == nil {
return fmt.Errorf("approval manager is not configured")
}
_, _, err := s.Approvals.Resolve(id, outcome)
return err
}
func (s *State) APITokens() ([]apitokens.Token, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
return apitokens.Entries(model)
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
}
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return apitokens.Token{}, "", err
}
token, err := apitokens.Find(model, id)
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return apitokens.Token{}, "", err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return token, secret, nil
}
func (s *State) UpsertAPIToken(token apitokens.Token) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
apitokens.Upsert(&model, token)
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) DisableAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Disable(token))
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) RevokeAPIToken(id string, when time.Time) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
apitokens.Upsert(&model, apitokens.Revoke(token, when))
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) DeleteAPIToken(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := apitokens.Delete(&model, id); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
security, ok := s.Session.(SecurityConfigurableSession)
if !ok {
return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings")
}
return security.SecuritySettings(), nil
}
func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error {
security, ok := s.Session.(SecurityConfigurableSession)
if !ok {
return fmt.Errorf("session does not expose security settings")
}
if err := security.ConfigureSecurity(settings); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) ShowSection(section Section) {
s.Section = section
s.CurrentPath = nil
s.SelectedEntryID = ""
}
func (s *State) SetSearchQuery(query string) {
s.SearchQuery = query
}
func (s *State) BeginNewEntry() {
s.SelectedEntryID = ""
s.StatusMessage = ""
s.ErrorMessage = ""
}
func (s *State) SetActionResult(label string, err error) {
if err != nil {
s.ErrorMessage = err.Error()
s.StatusMessage = ""
return
}
s.ErrorMessage = ""
s.StatusMessage = label + " complete"
}
func (s *State) VisibleEntries() ([]vault.Entry, error) {
@@ -303,7 +78,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
}
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 {
return entries, nil
@@ -376,26 +151,11 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit:
return nil
default:
return slices.Clone(model.Entries)
}
}
func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path)
switch s.Section {
case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" {
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...)
}
return strings.Join(path, " / ")
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
@@ -659,20 +419,6 @@ func (s *State) Unlock(key vault.MasterKey) error {
return session.Unlock(key)
}
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
session, ok := s.Session.(MasterKeyChangeableSession)
if !ok {
return fmt.Errorf("session does not support master key changes")
}
if err := session.ChangeMasterKey(key); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) EnterGroup(name string) {
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
s.SelectedEntryID = ""
@@ -697,80 +443,6 @@ func (s *State) Save() error {
return nil
}
func (s *State) Synchronize() error {
session, ok := s.Session.(SynchronizableSession)
if !ok {
return fmt.Errorf("session is not synchronizable")
}
if err := session.Synchronize(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeFromLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocal(path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeToLocal(path); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) SynchronizeFromRemote(client webdav.Client, path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromRemote(client, path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToRemote(client webdav.Client, path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeToRemote(client, path); err != nil {
return err
}
s.Dirty = true
return nil
}
func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession)
if !ok {
@@ -850,27 +522,6 @@ func (s *State) CreateGroup(name string) error {
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 {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -914,30 +565,6 @@ func (s *State) MoveSelectedEntry(path []string) error {
return nil
}
func (s *State) DeleteCurrentGroup() error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteGroup(s.CurrentPath); err != nil {
return err
}
session.Replace(model)
if len(s.CurrentPath) > 0 {
s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...)
}
s.SelectedEntryID = ""
s.Dirty = true
return nil
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
@@ -956,36 +583,6 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error
if model.Entries[i].Attachments == nil {
model.Entries[i].Attachments = map[string][]byte{}
}
if _, exists := model.Entries[i].Attachments[name]; exists {
return ErrAttachmentAlreadyExists
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if _, exists := model.Entries[i].Attachments[name]; !exists {
return ErrAttachmentNotFound
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
@@ -1010,9 +607,6 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if _, exists := model.Entries[i].Attachments[name]; !exists {
return ErrAttachmentNotFound
}
delete(model.Entries[i].Attachments, name)
if len(model.Entries[i].Attachments) == 0 {
model.Entries[i].Attachments = nil
+71 -741
View File
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
package assets
import (
"bytes"
"embed"
"image"
"image/png"
)
//go:embed *.png *.svg
var files embed.FS
func MustPNG(name string) image.Image {
data, err := files.ReadFile(name)
if err != nil {
panic(err)
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
panic(err)
}
return img
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
<title>KeePassGO icon</title>
<desc>Vault-shaped mark with lock, layered record lines, and navigation accent.</desc>
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<circle cx="128" cy="138" r="8" fill="#FFFFFF" opacity="0.2"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

-22
View File
@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="260" viewBox="0 0 920 260" fill="none">
<title>KeePassGO horizontal logo</title>
<desc>KeePassGO symbol with wordmark for light-theme desktop UI.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
</defs>
<rect width="920" height="260" fill="transparent"/>
<use href="#kpg-icon" x="24" y="20" width="176" height="176"/>
<text x="220" y="132" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="84" font-weight="650" fill="#3F4E63">KeePassGO</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

-75
View File
@@ -1,75 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" fill="none">
<title>KeePassGO splash screen</title>
<desc>Light-theme desktop splash screen with structured panels and the KeePassGO logo.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#D9E0E6" stop-opacity="0.9"/>
<stop offset="1" stop-color="#D9E0E6" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect width="1280" height="800" fill="#F7F7F5"/>
<g opacity="0.9">
<rect x="48" y="48" width="238" height="188" fill="#EEF2F4"/>
<rect x="286" y="48" width="152" height="104" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="438" y="48" width="214" height="104" fill="#E7EDF1"/>
<rect x="652" y="48" width="146" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="48" width="224" height="118" fill="#EEF2F4"/>
<rect x="1022" y="48" width="210" height="188" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="48" y="236" width="128" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="176" y="236" width="262" height="164" fill="#E7EDF1"/>
<rect x="438" y="236" width="160" height="84" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="598" y="236" width="200" height="164" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="236" width="434" height="164" fill="#EEF2F4"/>
<rect x="48" y="400" width="224" height="128" fill="#EEF2F4"/>
<rect x="272" y="400" width="166" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="438" y="400" width="360" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="798" y="400" width="152" height="128" fill="#E7EDF1"/>
<rect x="950" y="400" width="282" height="128" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="48" y="528" width="114" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="162" y="528" width="276" height="224" fill="#E7EDF1"/>
<rect x="438" y="528" width="212" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="650" y="528" width="318" height="224" fill="#EEF2F4"/>
<rect x="968" y="528" width="264" height="224" fill="#F7F7F5" stroke="#C7D0D8"/>
</g>
<g opacity="0.45">
<path d="M48 152H1232" stroke="#C7D0D8"/>
<path d="M48 320H1232" stroke="#C7D0D8"/>
<path d="M48 528H1232" stroke="#C7D0D8"/>
<path d="M176 48V752" stroke="#C7D0D8"/>
<path d="M438 48V752" stroke="#C7D0D8"/>
<path d="M798 48V752" stroke="#C7D0D8"/>
<path d="M1022 48V752" stroke="#C7D0D8"/>
</g>
<rect x="290" y="190" width="700" height="420" rx="18" fill="#F7F7F5" opacity="0.92"/>
<rect x="290" y="190" width="700" height="420" rx="18" stroke="#C7D0D8"/>
<rect x="290" y="190" width="700" height="120" rx="18" fill="url(#fade)"/>
<use href="#kpg-icon" x="530" y="232" width="220" height="220"/>
<text x="640" y="530" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
<text x="640" y="582" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
<g opacity="0.9">
<rect x="516" y="634" width="248" height="10" rx="5" fill="#D9E0E6"/>
<rect x="516" y="634" width="132" height="10" rx="5" fill="#6D89A8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-41
View File
@@ -1,41 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
<title>KeePassGO square splash preview</title>
<desc>Square crop of the KeePassGO splash system.</desc>
<defs>
<symbol id="kpg-icon" viewBox="0 0 256 256">
<polygon points="128,18 214,38 214,176 128,218 42,176 42,38" fill="#3F4E63"/>
<polygon points="128,34 198,50 198,166 128,200 58,166 58,50" fill="#55657A" opacity="0.16"/>
<polygon points="128,178 128,218 42,176 42,160" fill="#6D89A8"/>
<rect x="44" y="78" width="62" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<rect x="44" y="98" width="52" height="10" rx="2" fill="#FFFFFF" opacity="0.92"/>
<path d="M100 86C100 70.536 112.536 58 128 58C143.464 58 156 70.536 156 86V106H142V86C142 78.268 135.732 72 128 72C120.268 72 114 78.268 114 86V106H100V86Z" fill="#FFFFFF"/>
<rect x="76" y="102" width="104" height="64" rx="10" fill="#FFFFFF"/>
<rect x="80" y="106" width="96" height="56" rx="8" fill="#E9EDF0"/>
<path d="M128 118C139.046 118 148 126.954 148 138C148 145.669 143.68 152.329 137.338 155.7V173H118.662V155.7C112.32 152.329 108 145.669 108 138C108 126.954 116.954 118 128 118Z" fill="#3F4E63"/>
<path d="M178 130H214V150H166V144L178 130Z" fill="#E79A17"/>
<path d="M178 130H214V140H174L166 144V144L178 130Z" fill="#F0B13D" opacity="0.35"/>
</symbol>
</defs>
<rect width="1024" height="1024" fill="#F7F7F5"/>
<g opacity="0.9">
<rect x="32" y="32" width="180" height="184" fill="#EEF2F4"/>
<rect x="212" y="32" width="264" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="476" y="32" width="180" height="184" fill="#E7EDF1"/>
<rect x="656" y="32" width="336" height="140" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="656" y="172" width="336" height="180" fill="#EEF2F4"/>
<rect x="32" y="216" width="236" height="210" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="268" y="216" width="388" height="210" fill="#EEF2F4"/>
<rect x="32" y="426" width="180" height="268" fill="#E7EDF1"/>
<rect x="212" y="426" width="312" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="524" y="426" width="468" height="268" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="32" y="694" width="280" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
<rect x="312" y="694" width="268" height="298" fill="#EEF2F4"/>
<rect x="580" y="694" width="412" height="298" fill="#F7F7F5" stroke="#C7D0D8"/>
</g>
<rect x="162" y="190" width="700" height="644" rx="24" fill="#F7F7F5" opacity="0.95"/>
<rect x="162" y="190" width="700" height="644" rx="24" stroke="#C7D0D8"/>
<use href="#kpg-icon" x="332" y="252" width="360" height="360"/>
<text x="512" y="680" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="88" font-weight="650" fill="#3F4E63">KeePassGO</text>
<text x="512" y="740" text-anchor="middle" font-family="Inter, Noto Sans, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="#55657A">KeePass-compatible password manager</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-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)
}
}
-102
View File
@@ -1,102 +0,0 @@
package buildapk
import (
"fmt"
"os"
"path/filepath"
)
const (
DefaultSDKRoot = "/opt/android-sdk"
DefaultNDKRoot = "/opt/android-ndk"
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "assets/keepassgo-icon.png"
)
type Config struct {
SDKRoot string
NDKRoot string
JavaHome string
AppID string
APKOut string
Version string
MinSDK string
TargetSDK string
IconPath string
}
func DefaultConfig() Config {
return Config{
SDKRoot: DefaultSDKRoot,
NDKRoot: DefaultNDKRoot,
JavaHome: DefaultJavaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
}
}
func (c Config) GogioArgs() []string {
return []string{
"-target", "android",
"-buildmode", "exe",
"-appid", c.AppID,
"-o", c.APKOut,
"-version", c.Version,
"-minsdk", c.MinSDK,
"-targetsdk", c.TargetSDK,
"-icon", c.IconPath,
".",
}
}
func (c Config) Validate() error {
if !isExecutable(filepath.Join(c.JavaHome, "bin", "java")) {
return fmt.Errorf("JAVA_HOME must point to a JDK 17+ install")
}
if !isDir(c.SDKRoot) {
return fmt.Errorf("ANDROID_SDK_ROOT must point to an Android SDK install")
}
if !isDir(c.NDKRoot) {
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
}
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
return fmt.Errorf("Android SDK cmdline-tools are missing")
}
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK)
}
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
return fmt.Errorf("Android build-tools are missing")
}
if !isFile(c.IconPath) {
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath)
}
return nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return info.Mode()&0o111 != 0
}
func isFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
-101
View File
@@ -1,101 +0,0 @@
package buildapk
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestDefaultConfigGogioArgs(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
want := []string{
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath,
".",
}
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
t.Fatalf("GogioArgs() = %v, want %v", got, want)
}
}
func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
t.Parallel()
root := t.TempDir()
sdkRoot := filepath.Join(root, "sdk")
ndkRoot := filepath.Join(root, "ndk")
javaHome := filepath.Join(root, "java")
mkdirAll(t, filepath.Join(javaHome, "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin"))
mkdirAll(t, filepath.Join(sdkRoot, "platforms", "android-"+DefaultTargetSDK))
mkdirAll(t, filepath.Join(sdkRoot, "build-tools"))
mkdirAll(t, ndkRoot)
makeExecutable(t, filepath.Join(javaHome, "bin", "java"))
makeExecutable(t, filepath.Join(sdkRoot, "cmdline-tools", "latest", "bin", "sdkmanager"))
cfg := Config{
SDKRoot: sdkRoot,
NDKRoot: ndkRoot,
JavaHome: javaHome,
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
}
if err := os.WriteFile(cfg.IconPath, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", cfg.IconPath, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
}
func TestValidateRejectsMissingPrerequisites(t *testing.T) {
t.Parallel()
root := t.TempDir()
cfg := Config{
SDKRoot: filepath.Join(root, "missing-sdk"),
NDKRoot: filepath.Join(root, "missing-ndk"),
JavaHome: filepath.Join(root, "missing-java"),
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want missing prerequisite error")
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
}
func makeExecutable(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
+2 -22
View File
@@ -2,6 +2,7 @@ package clipboard
import (
"errors"
"fmt"
systemclipboard "github.com/atotto/clipboard"
@@ -9,7 +10,6 @@ import (
)
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
var ErrWriteFailed = errors.New("clipboard write failed")
type Target string
@@ -39,7 +39,7 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
return fmt.Errorf("write clipboard text: %w", err)
}
return nil
@@ -52,10 +52,6 @@ func (s Service) writer() Writer {
return systemWriter{}
}
func WriteText(text string) error {
return systemWriter{}.WriteText(text)
}
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == entryID {
@@ -83,19 +79,3 @@ type systemWriter struct{}
func (systemWriter) WriteText(text string) error {
return systemclipboard.WriteAll(text)
}
type writeError struct {
err error
}
func (e writeError) Error() string {
return ErrWriteFailed.Error()
}
func (e writeError) Unwrap() error {
return e.err
}
func (e writeError) Is(target error) bool {
return target == ErrWriteFailed
}
+9 -36
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
},
},
}
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target
want string
}{
{name: "username", target: TargetUsername, want: "dannyocean"},
{name: "username", target: TargetUsername, want: "joejulian"},
{name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
}
for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter
service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil {
if err := service.Copy(model, "git-server", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err)
}
@@ -60,33 +60,14 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
}
err = service.Copy(model, "vault-console", Target("unsupported"))
err = service.Copy(model, "git-server", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
}
}
func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
t.Parallel()
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Password: "token-1"},
},
}
err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
if err.Error() != ErrWriteFailed.Error() {
t.Fatalf("Copy() write error string = %q, want %q", err.Error(), ErrWriteFailed.Error())
}
}
type memoryWriter struct {
content string
}
@@ -95,11 +76,3 @@ func (w *memoryWriter) WriteText(text string) error {
w.content = text
return nil
}
type failingWriter struct {
err error
}
func (w failingWriter) WriteText(string) error {
return w.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)
}
}
-48
View File
@@ -1,48 +0,0 @@
# Accessibility Review
KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
## What is intentionally supported
- Keyboard focus is explicit for:
- vault search
- breadcrumb navigation
- entry list selection
- detail/editor fields
- Focus styling is visible and distinct from the unfocused field treatment.
- Common keyboard workflows are covered in-repo by tests for:
- tab navigation
- list navigation
- search focus
- new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
## Current screen-reader boundary
- Gio does not currently give KeePassGO a full native accessibility tree comparable to mature desktop UI toolkits.
- KeePassGO therefore treats screen-reader support as:
- label-conscious where the toolkit exposes focusable controls
- limited where platform assistive APIs are not surfaced by Gio in the same way as native toolkit widgets
- In practice, this means keyboard and focus behavior are first-class and tested, while spoken output quality still depends on Gio/platform limitations outside this repo.
## Current review result
- Linux:
- keyboard/focus behavior is intentionally supported
- visible focus states and control naming are present in code
- full Orca-style semantic verification is not something this repo can assert automatically today
- Windows:
- the same keyboard/focus behavior and explicit labels are present in-app
- full UI Automation parity cannot be claimed from inside this codebase without broader Gio support
## What KeePassGO should continue doing
- Keep every major workflow operable without a pointer device.
- Add explicit labels for any new focusable control.
- Preserve visible focus treatment for new form fields, buttons, and dialogs.
- Prefer dialogs and panels that keep keyboard focus predictable.
## What remains toolkit-limited
- Rich screen-reader semantics beyond the control labeling and focus management done in this repository.
- Native assistive-technology parity with toolkits that expose a fuller accessibility object model.
+3 -8
View File
@@ -5,14 +5,9 @@ KeePassGO supports the following KDBX security workflows today:
- open and save password-only vaults
- open and save key-file-only vaults
- open and save composite password-plus-key-file vaults
- select the active master-key mode in the product UI for create, open, and unlock flows
- change an existing session to a new master-key mode before saving
- preserve the original opened vault's KDBX format version during save
- preserve the original opened vault's cipher selection during save
- preserve the original opened vault's KDF selection during save
- choose the cipher family for new vault creation
- choose the KDF family for new vault creation
- change the cipher family and KDF family for an existing unlocked session before the next save
What "preserve" means:
@@ -21,11 +16,11 @@ What "preserve" means:
Current explicit limitations:
- KeePassGO currently exposes major cipher/KDF family choices, not every low-level tuning parameter from KeePass
- advanced KDF tuning such as custom Argon2 memory/parallelism and AES-KDF round-count editing is not yet a product-facing control
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
- new vault creation still uses the library default KDBX header settings for freshly created databases
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
Practical expectation:
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
- KeePassGO now lets a user select the major cipher/KDF family, while still avoiding a full low-level database-header editor
- KeePassGO does not yet try to be a full advanced database-settings editor
+2 -13
View File
@@ -2,11 +2,8 @@ module git.julianfamily.org/keepassgo
go 1.26
replace gioui.org/cmd => ./third_party/gioui-cmd
require (
gioui.org v0.8.0
gioui.org/x v0.8.0
gioui.org v0.9.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -17,9 +14,7 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
@@ -31,7 +26,6 @@ require (
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/akavel/rsrc v0.10.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
@@ -76,7 +70,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -195,7 +188,6 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
@@ -215,7 +207,4 @@ require (
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
tool (
gioui.org/cmd/gogio
github.com/golangci/golangci-lint/cmd/golangci-lint
)
tool github.com/golangci/golangci-lint/cmd/golangci-lint
+2 -24
View File
@@ -37,15 +37,11 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
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 v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -70,8 +66,6 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
@@ -130,10 +124,6 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -222,14 +212,6 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -368,8 +350,6 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -402,8 +382,6 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
+400 -5327
View File
File diff suppressed because it is too large Load Diff
+81 -5337
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
-27
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"math/big"
"slices"
"strings"
)
@@ -17,7 +16,6 @@ const (
)
var ErrImpossibleProfile = errors.New("impossible password profile")
var ErrUnknownProfile = errors.New("unknown password profile")
type Profile struct {
Name string
@@ -63,31 +61,6 @@ func DefaultProfiles() map[string]Profile {
}
}
func DefaultProfileNames() []string {
return ProfileNames(DefaultProfiles())
}
func LookupProfile(name string, profiles map[string]Profile) (Profile, error) {
profile, ok := profiles[strings.TrimSpace(name)]
if !ok {
return Profile{}, fmt.Errorf("%w %q", ErrUnknownProfile, strings.TrimSpace(name))
}
return profile, nil
}
func LookupDefaultProfile(name string) (Profile, error) {
return LookupProfile(name, DefaultProfiles())
}
func ProfileNames(profiles map[string]Profile) []string {
names := make([]string, 0, len(profiles))
for name := range profiles {
names = append(names, name)
}
slices.Sort(names)
return names
}
func Generate(profile Profile) (string, error) {
if err := validateProfile(profile); err != nil {
return "", err
+11 -40
View File
@@ -1,8 +1,6 @@
package passwords
import (
"errors"
"slices"
"strings"
"testing"
)
@@ -11,17 +9,17 @@ func TestGenerateRespectsProfileRequirements(t *testing.T) {
t.Parallel()
profile := Profile{
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
}
password, err := Generate(profile)
@@ -88,33 +86,6 @@ func TestProfileSetReturnsNamedProfiles(t *testing.T) {
}
}
func TestDefaultProfileNamesReturnsSortedNames(t *testing.T) {
t.Parallel()
got := DefaultProfileNames()
want := []string{"memorable", "strong"}
if !slices.Equal(got, want) {
t.Fatalf("DefaultProfileNames() = %v, want %v", got, want)
}
}
func TestLookupDefaultProfileResolvesKnownProfilesAndRejectsUnknownNames(t *testing.T) {
t.Parallel()
profile, err := LookupDefaultProfile(" strong ")
if err != nil {
t.Fatalf("LookupDefaultProfile(\" strong \") error = %v", err)
}
if profile.Name != "strong" {
t.Fatalf("LookupDefaultProfile(\" strong \").Name = %q, want %q", profile.Name, "strong")
}
_, err = LookupDefaultProfile("invalid")
if !errors.Is(err, ErrUnknownProfile) {
t.Fatalf("LookupDefaultProfile(\"invalid\") error = %v, want ErrUnknownProfile", err)
}
}
func countFromSet(password, chars string) int {
count := 0
for _, r := range password {
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -15,7 +15,6 @@ service VaultService {
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
@@ -68,10 +67,7 @@ message LockVaultRequest {}
message LockVaultResponse {}
message UnlockVaultRequest {
string password = 1;
bytes key_file_data = 2;
}
message UnlockVaultRequest {}
message UnlockVaultResponse {}
@@ -89,7 +85,6 @@ message Entry {
string notes = 6;
repeated string tags = 7;
repeated string path = 8;
map<string, string> fields = 9;
}
message ListEntriesResponse {
@@ -118,12 +113,6 @@ message RenameGroupRequest {
message RenameGroupResponse {}
message DeleteGroupRequest {
repeated string path = 1;
}
message DeleteGroupResponse {}
message UpsertEntryRequest {
Entry entry = 1;
}
-38
View File
@@ -29,7 +29,6 @@ const (
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
@@ -61,7 +60,6 @@ type VaultServiceClient interface {
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error)
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
@@ -187,16 +185,6 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq
return out, nil
}
func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGroupResponse)
err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertEntryResponse)
@@ -361,7 +349,6 @@ type VaultServiceServer interface {
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
@@ -417,9 +404,6 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
}
func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented")
}
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
}
@@ -666,24 +650,6 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGroupRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).DeleteGroup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_DeleteGroup_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertEntryRequest)
if err := dec(in); err != nil {
@@ -1001,10 +967,6 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RenameGroup",
Handler: _VaultService_RenameGroup_Handler,
},
{
MethodName: "DeleteGroup",
Handler: _VaultService_DeleteGroup_Handler,
},
{
MethodName: "UpsertEntry",
Handler: _VaultService_UpsertEntry_Handler,
-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())
+46 -877
View File
File diff suppressed because it is too large Load Diff
+33 -723
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
}
}
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
@@ -175,80 +175,6 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
}
}
func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
path := filepath.Join(t.TempDir(), "hidden-root.kdbx")
var initial bytes.Buffer
if err := vault.SaveKDBX(&initial, vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Crew", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "dannyocean",
Password: "token-2",
URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Crew", "eMail"},
},
},
}, key.Password); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, initial.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(hidden-root.kdbx) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current)
if err := sess.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
reopened, err := os.Open(path)
if err != nil {
t.Fatalf("Open(saved path) error = %v", err)
}
defer reopened.Close()
db := gokeepasslib.NewDatabase()
db.Credentials = gokeepasslib.NewPasswordCredentials(key.Password)
if err := gokeepasslib.NewDecoder(reopened).Decode(db); err != nil {
t.Fatalf("Decode(saved path) error = %v", err)
}
if err := db.UnlockProtectedEntries(); err != nil {
t.Fatalf("UnlockProtectedEntries() error = %v", err)
}
if len(db.Content.Root.Groups) != 1 || db.Content.Root.Groups[0].Name != "keepass" {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
}
}
func TestSaveWithoutPathFails(t *testing.T) {
t.Parallel()
@@ -271,10 +197,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -309,7 +235,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
}
}
@@ -321,10 +247,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-1",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -371,10 +297,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
@@ -406,10 +332,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -448,10 +374,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
@@ -465,68 +391,6 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
}
func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Parallel()
originalKey := vault.MasterKey{Password: "old-password"}
updatedKey := vault.MasterKey{
Password: "new-password",
KeyFileData: []byte("updated-key-file"),
}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
var sess Manager
if err := sess.Create(model, originalKey); err != nil {
t.Fatalf("Create() error = %v", err)
}
if err := sess.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if err := sess.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if err := sess.ChangeMasterKey(updatedKey); err != nil {
t.Fatalf("ChangeMasterKey() error = %v", err)
}
if err := sess.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
if err := sess.Unlock(updatedKey); err != nil {
t.Fatalf("Unlock(updatedKey) error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
}
var reopened Manager
if err := reopened.Open(path, updatedKey); err != nil {
t.Fatalf("Open(updatedKey) error = %v", err)
}
if err := reopened.Open(path, originalKey); !errors.Is(err, vault.ErrInvalidMasterKey) {
t.Fatalf("Open(originalKey) error = %v, want ErrInvalidMasterKey", err)
}
}
func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
t.Parallel()
@@ -540,10 +404,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
},
},
},
@@ -577,10 +441,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID,
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: current.Entries[0].Path,
})
sess.Replace(current)
@@ -611,557 +475,3 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
}
}
func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
},
},
Templates: []vault.Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
RecycleBin: []vault.Entry{
{
ID: "deleted-1",
Title: "Retired Entry",
Username: "archived-user",
Password: "retired-token",
Path: []string{"Root", "Archive"},
},
},
Groups: [][]string{
{"Root", "Archive"},
{"Root", "Empty Group"},
{"Templates", "Web"},
},
}
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
if got := r.Header.Get("If-Match"); got != etag {
t.Fatalf("If-Match header = %q, want %q", got, etag)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
etag = "\"v2\""
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
if err := sess.SaveRemote(); err != nil {
t.Fatalf("SaveRemote() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("reopen OpenRemote() error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("Current() after reopen error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
}
if got[0].ID != "entry-1" {
t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1")
}
if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" {
t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History)
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" {
t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates)
}
if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" {
t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin)
}
}
func TestConfigureSecurityAppliesToCreatedVaultAndPersists(t *testing.T) {
t.Parallel()
var sess Manager
if err := sess.ConfigureSecurity(vault.SecuritySettings{
Cipher: vault.CipherAES256,
KDF: vault.KDFAES,
}); err != nil {
t.Fatalf("ConfigureSecurity() error = %v", err)
}
if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
path := filepath.Join(t.TempDir(), "secure.kdbx")
if err := sess.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
var reopened Manager
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Open() error = %v", err)
}
got := reopened.SecuritySettings()
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
}
}
func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
if got := r.Header.Get("If-Match"); got != etag {
t.Fatalf("If-Match header = %q, want %q", got, etag)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
switch etag {
case "\"v1\"":
etag = "\"v2\""
default:
etag = "\"v3\""
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var first Manager
if err := first.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("first OpenRemote() error = %v", err)
}
var second Manager
if err := second.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("second OpenRemote() error = %v", err)
}
firstCurrent, err := first.Current()
if err != nil {
t.Fatalf("first Current() error = %v", err)
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "remote-token-2",
URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
first.Replace(firstCurrent)
if err := first.SaveRemote(); err != nil {
t.Fatalf("first SaveRemote() error = %v", err)
}
secondCurrent, err := second.Current()
if err != nil {
t.Fatalf("second Current() error = %v", err)
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "local-token-2",
URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
if err := second.Synchronize(); err != nil {
t.Fatalf("second Synchronize() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("reopened OpenRemote() 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) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
}
if len(got[0].History) == 0 {
t.Fatal("len(History) = 0, want overwritten remote variant preserved")
}
if got[0].History[0].Password != "remote-token-2" || got[0].History[0].Notes != "updated remotely first" {
t.Fatalf("History[0] = %#v, want displaced remote version first", got[0].History[0])
}
}
func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
otherPath := filepath.Join(t.TempDir(), "other.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)
writeKDBXTestFile(t, otherPath, otherModel, key)
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocal(otherPath); err != nil {
t.Fatalf("SynchronizeFromLocal() 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 TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
otherPath := filepath.Join(t.TempDir(), "other.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)
writeKDBXTestFile(t, otherPath, otherModel, key)
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeToLocal(otherPath); err != nil {
t.Fatalf("SynchronizeToLocal() error = %v", err)
}
var reopened Manager
if err := reopened.Open(otherPath, key); err != nil {
t.Fatalf("reopen Open(other) 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 TestSynchronizeToRemoteWritesMergedVaultToTarget(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"},
},
},
}
remoteModel := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-remote",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var remoteBytes bytes.Buffer
if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil {
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
}
etag := "\"v1\""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("ETag", etag)
_, _ = w.Write(remoteBytes.Bytes())
case http.MethodPut:
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
remoteBytes.Reset()
if _, err := remoteBytes.Write(payload); err != nil {
t.Fatalf("Write(remoteBytes) error = %v", err)
}
etag = "\"v2\""
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeToRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx"); err != nil {
t.Fatalf("SynchronizeToRemote() error = %v", err)
}
var reopened Manager
if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil {
t.Fatalf("OpenRemote(reopened) 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 writeKDBXTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) {
t.Helper()
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error = %v", path, err)
}
}
-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,
)
}
`
-135
View File
@@ -1,135 +0,0 @@
package main
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
switch field {
case detailFieldID:
return "ID"
case detailFieldTitle:
return "Title"
case detailFieldUsername:
return "Username"
case detailFieldPassword:
return "Password"
case detailFieldURL:
return "URL"
case detailFieldPath:
return "Path"
case detailFieldTags:
return "Tags"
case detailFieldPasswordProfile:
return "Password Profile"
case detailFieldNotes:
return "Notes"
case detailFieldFields:
return "Custom Fields"
case detailFieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
-1178
View File
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
package main
import (
"image"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
)
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if u.mode != "phone" {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.mode == "phone" {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
+34 -216
View File
@@ -3,48 +3,16 @@ package main
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/appstate"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault"
)
func (u *ui) attachmentInput() (string, []byte, error) {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "", nil, fmt.Errorf("attachment name is required")
}
path := strings.TrimSpace(u.attachmentPath.Text())
if path == "" {
return "", nil, fmt.Errorf("attachment path is required")
}
info, err := os.Stat(path)
if err != nil {
return "", nil, fmt.Errorf("stat attachment: %w", err)
}
if info.Size() > maxAttachmentBytes {
return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes)
}
content, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("read attachment: %w", err)
}
return name, content, nil
}
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
item, ok := u.selectedEntry()
if !ok {
u.entryID.SetText("")
@@ -54,9 +22,8 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryURL.SetText("")
u.entryNotes.SetText("")
u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.entryFields.SetText("")
u.setCustomFieldRows(nil)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
@@ -70,103 +37,13 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryURL.SetText(item.URL)
u.entryNotes.SetText(item.Notes)
u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
u.entryPath.SetText(strings.Join(item.Path, " / "))
u.entryFields.SetText(marshalFields(item.Fields))
u.setCustomFieldRows(item.Fields)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
}
func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
u.appendCustomFieldRow(key, fields[key])
}
}
func (u *ui) appendCustomFieldRow(key, value string) {
keyEditor := widget.Editor{SingleLine: true, Submit: false}
keyEditor.SetText(key)
valueEditor := widget.Editor{SingleLine: true, Submit: false}
valueEditor.SetText(value)
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
value := strings.TrimSpace(u.customFieldValues[i].Text())
if key == "" && value == "" {
continue
}
if key == "" {
return nil, fmt.Errorf("custom field name is required")
}
fields[key] = value
}
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
return parseFields(u.entryFields.Text())
}
if len(fields) == 0 {
return nil, nil
}
return fields, nil
}
func (u *ui) visibleHistory() []vault.Entry {
item, ok := u.selectedEntry()
if !ok || len(item.History) == 0 {
return nil
}
return append([]vault.Entry(nil), item.History...)
}
func (u *ui) selectedHistoryEntry() (vault.Entry, bool) {
history := u.visibleHistory()
if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) {
return vault.Entry{}, false
}
return history[u.selectedHistoryIndex], true
}
func (u *ui) selectHistoryVersion(index int) error {
history := u.visibleHistory()
if index < 0 || index >= len(history) {
return fmt.Errorf("history index %d out of range", index)
}
u.selectedHistoryIndex = index
u.historyIndex.SetText(strconv.Itoa(index))
return nil
}
func (u *ui) saveEntryAction() error {
entry, err := u.editorEntry()
if err != nil {
@@ -175,8 +52,6 @@ func (u *ui) saveEntryAction() error {
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -230,8 +105,6 @@ func (u *ui) saveTemplateAction() error {
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
@@ -250,33 +123,32 @@ func (u *ui) deleteSelectedTemplateAction() error {
}
func (u *ui) createGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) deleteCurrentGroupAction() error {
if !u.deleteGroupPendingConfirmation() {
return fmt.Errorf("confirm deleting the empty group first")
session, ok := u.state.Session.(appstate.MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
if err := u.state.DeleteCurrentGroup(); err != nil {
model, err := session.Current()
if err != nil {
return err
}
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
if err := model.DeleteGroup(u.currentPath); err != nil {
return err
}
session.Replace(model)
if len(u.currentPath) > 0 {
u.currentPath = append([]string(nil), u.currentPath[:len(u.currentPath)-1]...)
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.state.Dirty = true
u.filter()
return nil
}
@@ -299,25 +171,17 @@ func (u *ui) instantiateSelectedTemplateAction() error {
}
func (u *ui) addAttachmentAction() error {
name, content, err := u.attachmentInput()
content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text()))
if err != nil {
return err
return fmt.Errorf("read attachment: %w", err)
}
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) replaceAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil {
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
@@ -338,11 +202,7 @@ func (u *ui) exportAttachmentAction() error {
return fmt.Errorf("attachment not found")
}
exportPath := strings.TrimSpace(u.exportAttachmentPath.Text())
if exportPath == "" {
return fmt.Errorf("export attachment path is required")
}
if err := os.WriteFile(exportPath, content, 0o600); err != nil {
if err := os.WriteFile(strings.TrimSpace(u.exportAttachmentPath.Text()), content, 0o600); err != nil {
return fmt.Errorf("write attachment export: %w", err)
}
return nil
@@ -363,9 +223,9 @@ func (u *ui) removeAttachmentAction() error {
}
func (u *ui) restoreSelectedHistoryAction() error {
index, err := u.selectedHistoryVersionIndex()
index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text()))
if err != nil {
return err
return fmt.Errorf("invalid history index: %w", err)
}
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
return err
@@ -375,35 +235,21 @@ func (u *ui) restoreSelectedHistoryAction() error {
return nil
}
func (u *ui) selectedHistoryVersionIndex() (int, error) {
text := strings.TrimSpace(u.historyIndex.Text())
if text != "" {
index, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("invalid history index: %w", err)
}
return index, nil
}
if u.selectedHistoryIndex >= 0 {
return u.selectedHistoryIndex, nil
}
return 0, fmt.Errorf("no history version selected")
}
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
service := clipboard.Service{}
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
return err
profiles := passwords.DefaultProfiles()
profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())]
if !ok {
return fmt.Errorf("unknown password profile")
}
password, err := passwords.Generate(profile)
@@ -411,16 +257,12 @@ func (u *ui) generatePasswordAction() error {
return err
}
u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil
}
func (u *ui) editorEntry() (vault.Entry, error) {
path := parsePath(u.entryPath.Text())
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
path = append([]string{root}, path...)
}
fields, err := u.currentCustomFields()
fields, err := parseFields(u.entryFields.Text())
if err != nil {
return vault.Entry{}, err
}
@@ -493,27 +335,3 @@ func marshalFields(fields map[string]string) string {
}
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)
}
+101 -1473
View File
File diff suppressed because it is too large Load Diff
-378
View File
@@ -1,378 +0,0 @@
package main
import (
"fmt"
"strconv"
"strings"
"gioui.org/io/key"
"git.julianfamily.org/keepassgo/appstate"
)
type focusID string
type detailField string
const (
focusSearch focusID = "search"
detailFieldID detailField = "id"
detailFieldTitle detailField = "title"
detailFieldUsername detailField = "username"
detailFieldPassword detailField = "password"
detailFieldURL detailField = "url"
detailFieldPath detailField = "path"
detailFieldTags detailField = "tags"
detailFieldPasswordProfile detailField = "password-profile"
detailFieldNotes detailField = "notes"
detailFieldFields detailField = "fields"
detailFieldHistoryIndex detailField = "history-index"
)
func breadcrumbFocusID(index int) focusID {
return focusID(fmt.Sprintf("breadcrumb:%d", index))
}
func listFocusID(index int) focusID {
return focusID(fmt.Sprintf("list:%d", index))
}
func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
}
if u.isVaultLocked() && name == key.NameReturn {
u.startUnlockAction()
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.startOpenRemoteAction()
} else {
u.startOpenVaultAction()
}
return true
}
switch name {
case key.NameTab:
delta := 1
if modifiers.Contain(key.ModShift) {
delta = -1
}
u.moveKeyboardFocus(delta)
return true
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
return u.handleFocusedKey(name)
default:
return false
}
}
func (u *ui) moveKeyboardFocus(delta int) {
order := u.focusOrder()
if len(order) == 0 {
return
}
current := canonicalFocusID(u.keyboardFocus)
index := 0
for i, item := range order {
if canonicalFocusID(item) == current {
index = i
break
}
}
index += delta
if index < 0 {
index = len(order) - 1
}
if index >= len(order) {
index = 0
}
u.setKeyboardFocus(order[index])
}
func (u *ui) focusOrder() []focusID {
if u.isVaultLocked() {
return []focusID{detailFocusID(detailFieldPassword)}
}
order := []focusID{focusSearch}
if u.state.Section != appstate.SectionRecycleBin {
order = append(order, breadcrumbFocusID(0))
}
if len(u.visible) > 0 {
order = append(order, listFocusID(u.focusedListIndexOrZero()))
}
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
return order
}
func (u *ui) setKeyboardFocus(id focusID) {
u.keyboardFocus = id
if strings.HasPrefix(string(id), "list:") {
u.focusListIndex(focusIndex(id))
}
}
func (u *ui) handleFocusedKey(name key.Name) bool {
switch {
case u.keyboardFocus == focusSearch:
if name == key.NameDownArrow && len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
return true
}
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
return u.handleBreadcrumbKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
return u.handleListKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
return u.handleDetailKey(name)
}
return false
}
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
crumbs := u.breadcrumbLabels()
if len(crumbs) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameLeftArrow:
if index > 0 {
u.keyboardFocus = breadcrumbFocusID(index - 1)
}
return true
case key.NameRightArrow:
if index < len(crumbs)-1 {
u.keyboardFocus = breadcrumbFocusID(index + 1)
}
return true
case key.NameDownArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
case key.NameReturn:
u.activateBreadcrumb(index)
return true
default:
return false
}
}
func (u *ui) handleListKey(name key.Name) bool {
if len(u.visible) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameUpArrow:
if index > 0 {
u.setKeyboardFocus(listFocusID(index - 1))
}
return true
case key.NameDownArrow:
if index < len(u.visible)-1 {
u.setKeyboardFocus(listFocusID(index + 1))
}
return true
case key.NameLeftArrow:
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
return true
case key.NameRightArrow, key.NameReturn:
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
return true
default:
return false
}
}
func (u *ui) handleDetailKey(name key.Name) bool {
fields := detailFocusOrder()
index := u.focusedDetailIndex()
switch name {
case key.NameUpArrow:
if index > 0 {
u.keyboardFocus = detailFocusID(fields[index-1])
}
return true
case key.NameDownArrow:
if index < len(fields)-1 {
u.keyboardFocus = detailFocusID(fields[index+1])
}
return true
case key.NameLeftArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
default:
return false
}
}
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
if !modifiers.Contain(key.ModShortcut) {
return false
}
switch name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
default:
return false
}
return true
}
func (u *ui) activateBreadcrumb(index int) {
var path []string
if index <= 0 {
path = nil
} else {
crumbs := u.breadcrumbLabels()
path = append([]string{}, crumbs[1:index+1]...)
}
u.state.NavigateToPath(path)
u.filter()
if index >= len(u.breadcrumbLabels()) {
index = len(u.breadcrumbLabels()) - 1
}
if index < 0 {
index = 0
}
u.keyboardFocus = breadcrumbFocusID(index)
}
func (u *ui) breadcrumbLabels() []string {
if u.state.Section == appstate.SectionRecycleBin {
return nil
}
labels := append([]string{"Vault"}, u.state.CurrentPath...)
if u.state.Section == appstate.SectionTemplates {
labels = append([]string{"Templates"}, u.state.CurrentPath...)
}
return labels
}
func (u *ui) focusListIndex(index int) {
if len(u.visible) == 0 {
return
}
if index < 0 {
index = 0
}
if index >= len(u.visible) {
index = len(u.visible) - 1
}
u.keyboardFocus = listFocusID(index)
u.state.SelectedEntryID = u.visible[index].ID
u.loadSelectedEntryIntoEditor()
}
func (u *ui) focusedListIndexOrZero() int {
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
index := focusIndex(u.keyboardFocus)
if index >= 0 && index < len(u.visible) {
return index
}
}
for i, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return i
}
}
return 0
}
func (u *ui) focusedDetailFieldOrDefault() detailField {
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
for _, field := range detailFocusOrder() {
if string(field) == name {
return field
}
}
}
return detailFieldTitle
}
func (u *ui) focusedDetailIndex() int {
current := u.focusedDetailFieldOrDefault()
for i, field := range detailFocusOrder() {
if field == current {
return i
}
}
return 0
}
func detailFocusOrder() []detailField {
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
}
func canonicalFocusID(id focusID) focusID {
switch {
case strings.HasPrefix(string(id), "breadcrumb:"):
return breadcrumbFocusID(0)
case strings.HasPrefix(string(id), "list:"):
return listFocusID(0)
case strings.HasPrefix(string(id), "detail:"):
return detailFocusID(detailFieldTitle)
default:
return id
}
}
func focusIndex(id focusID) int {
_, value, ok := strings.Cut(string(id), ":")
if !ok {
return 0
}
index, err := strconv.Atoi(value)
if err != nil {
return 0
}
return index
}
-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
}
+24 -22
View File
@@ -24,21 +24,13 @@ func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "O", Required: key.ModShortcut},
)
if !ok {
break
@@ -49,9 +41,21 @@ func (u *ui) processShortcuts(gtx layout.Context) {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
switch ke.Name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
}
}
}
@@ -59,17 +63,15 @@ func (u *ui) processShortcuts(gtx layout.Context) {
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.state.SelectedEntryID = ""
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Original note",
Path: []string{"Root", "Internet"},
},
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
}
model.UpsertEntry(Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Updated note",
Path: []string{"Root", "Internet"},
})
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "surveillance-console",
Title: "Surveillance Console",
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
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)
}
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
}
if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", 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, "Home Assistant (Codex)")
}
}
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{
RecycleBin: []Entry{
{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "token-3",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
if err := model.RestoreEntry("bellagio"); err != nil {
if err := model.RestoreEntry("dynadot"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
}
if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
Notes: "Current note",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "vault-console-history-1",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server-history-1",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
Notes: "Previous note",
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)
}
+51 -92
View File
@@ -23,8 +23,8 @@ type KDBXConfig struct {
var ErrInvalidMasterKey = errors.New("invalid master key")
const (
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID"
)
@@ -46,29 +46,33 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
return err
}
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = credentials
db.Content.Meta = gokeepasslib.NewMetaData()
db.Content.Root = &gokeepasslib.RootData{}
header := gokeepasslib.NewHeader()
if config != nil && config.Header != nil {
db.Header = cloneHeader(config.Header)
db.Hashes = gokeepasslib.NewHashes(db.Header)
header = cloneHeader(config.Header)
}
if db.Header.IsKdbx4() {
content := &gokeepasslib.DBContent{
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{},
}
if header.IsKdbx4() {
if config != nil && config.InnerHeader != nil {
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
db.Content.InnerHeader.Binaries = nil
} else if db.Content.InnerHeader == nil {
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
} else {
content.InnerHeader = &gokeepasslib.InnerHeader{
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
InnerRandomStreamKey: randomBytes(64),
}
}
} else {
db.Content.InnerHeader = nil
}
db.Content.Root.Groups = buildGroupTree(db, model)
db.Content.Root.DeletedObjects = nil
db := &gokeepasslib.Database{
Header: header,
Credentials: credentials,
Content: content,
Hashes: gokeepasslib.NewHashes(header),
}
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
if err := db.LockProtectedEntries(); err != nil {
return fmt.Errorf("lock protected entries: %w", err)
@@ -83,21 +87,20 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
path = append(clonePath(path), group.Name)
model.CreateGroup(path[:len(path)-1], group.Name)
for _, entry := range group.Entries {
appendModelEntry(model, Entry{
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
ID: extractEntryID(entry),
Title: entry.GetTitle(),
Username: entry.GetContent("UserName"),
Password: entry.GetPassword(),
URL: entry.GetContent("URL"),
Notes: entry.GetContent("Notes"),
Tags: splitTags(entry.Tags),
Fields: extractCustomFields(entry),
Attachments: extractAttachments(db, entry),
History: extractHistory(db, entry, path),
Path: clonePath(path),
History: extractHistory(db, entry, path),
Path: clonePath(path),
})
}
@@ -204,7 +207,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
for _, item := range entry.Histories {
for _, historical := range item.Entries {
history = append(history, Entry{
ID: extractEntryID(historical),
ID: marshalUUID(historical.UUID),
Title: historical.GetTitle(),
Username: historical.GetContent("UserName"),
Password: historical.GetPassword(),
@@ -232,8 +235,7 @@ type MasterKey struct {
KeyFileData []byte
}
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
entries := entriesForPersistence(model)
func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group {
root := &groupNode{children: map[string]*groupNode{}}
for _, entry := range entries {
node := root
@@ -248,18 +250,6 @@ func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group
}
node.entries = append(node.entries, entry)
}
for _, path := range groupPathsForPersistence(model, entries) {
node := root
for _, segment := range path {
if node.children[segment] == nil {
node.children[segment] = &groupNode{
name: segment,
children: map[string]*groupNode{},
}
}
node = node.children[segment]
}
}
groups := marshalGroups(db, root)
if len(groups) > 0 {
@@ -271,31 +261,6 @@ func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group
return []gokeepasslib.Group{group}
}
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
seen := map[string]bool{}
var groups [][]string
appendPath := func(path []string) {
key := strings.Join(path, "\x00")
if seen[key] {
return
}
seen[key] = true
groups = append(groups, slices.Clone(path))
}
for _, entry := range entries {
for i := 1; i <= len(entry.Path); i++ {
appendPath(entry.Path[:i])
}
}
for _, path := range model.Groups {
for i := 1; i <= len(path); i++ {
appendPath(path[:i])
}
}
return groups
}
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
model, _, err := LoadKDBXWithConfig(r, key)
return model, err
@@ -442,7 +407,7 @@ func isInvalidCredentialError(err error) bool {
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
names := slices.Collect(maps.Keys(node.children))
slices.SortFunc(names, compareGroupNames)
slices.Sort(names)
var groups []gokeepasslib.Group
for _, name := range names {
@@ -457,29 +422,6 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
return groups
}
func compareGroupNames(a, b string) int {
switch {
case a == b:
return 0
case a == "Root":
return -1
case b == "Root":
return 1
case a == templatesRoot:
return -1
case b == templatesRoot:
return 1
case a == recycleBinRoot:
return 1
case b == recycleBinRoot:
return -1
case a < b:
return -1
default:
return 1
}
}
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
slices.SortFunc(entries, func(a, b Entry) int {
switch {
@@ -535,6 +477,23 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
return item
}
func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData {
if len(entries) == 0 {
return nil
}
deletionTime := w.Now()
out := make([]gokeepasslib.DeletedObjectData, 0, len(entries))
for _, entry := range entries {
out = append(out, gokeepasslib.DeletedObjectData{
UUID: uuidForEntryID(entry.ID),
DeletionTime: &deletionTime,
})
}
return out
}
func uuidForEntryID(id string) gokeepasslib.UUID {
if id != "" {
var uuid gokeepasslib.UUID

Some files were not shown because too many files have changed in this diff Show More