Compare commits
46 Commits
0.2.0
..
4b7a898a7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b7a898a7f | |||
| 5bf1bdb5f3 | |||
| c09ae8a5fb | |||
| e4e64504b8 | |||
| 3afe9871e7 | |||
| 60172d8c6d | |||
| 84512172f3 | |||
| 26748d4b84 | |||
| 554d4f760d | |||
| 94ee7fd4c0 | |||
| 009c4ecbf6 | |||
| 400278ce0c | |||
| f6aff3e255 | |||
| 53f7a702fe | |||
| 57d6a304db | |||
| 99ff89de32 | |||
| fc59fb4869 | |||
| 8866d9c06f | |||
| b57a671819 | |||
| 03ea224773 | |||
| 8b4f3f91be | |||
| 839cc6cc1c | |||
| 574e2a2762 | |||
| 71a0d75886 | |||
| 73eea0a066 | |||
| 30cab83eaa | |||
| f1ac24efb9 | |||
| fe9f3ae112 | |||
| d037393a16 | |||
| 4f5465dfff | |||
| 05345559ee | |||
| af44810a1c | |||
| 48a21c2c59 | |||
| fc921ca30f | |||
| db74a0a198 | |||
| b27e2f168c | |||
| adf91445c3 | |||
| 37297856c4 | |||
| dc03d6a0a5 | |||
| 4da9c07b32 | |||
| ef01e33990 | |||
| 66d105d2a9 | |||
| 677464dc9a | |||
| 79c5176487 | |||
| 5e1e9f82fb | |||
| 32c7b49f4f |
@@ -1,109 +0,0 @@
|
|||||||
---
|
|
||||||
name: keepassgo-ship-it
|
|
||||||
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
|
||||||
---
|
|
||||||
|
|
||||||
# KeePassGO Ship It
|
|
||||||
|
|
||||||
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
|
||||||
|
|
||||||
Use it together with:
|
|
||||||
- `android-emulator-debug` for emulator and `adb` mechanics
|
|
||||||
- `keepass-credentials` for Nextcloud credentials
|
|
||||||
- `public-repo-sanitization` before the commit/push step
|
|
||||||
|
|
||||||
## Meaning Of `ship it`
|
|
||||||
|
|
||||||
When the user says `ship it`, do all of this unless they narrow the scope:
|
|
||||||
|
|
||||||
1. Commit the relevant KeePassGO source changes first.
|
|
||||||
2. Build and install the Arch package from that committed source.
|
|
||||||
3. Build the Android APK from that same committed source.
|
|
||||||
4. Zip the APK.
|
|
||||||
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
|
||||||
6. Install the rebuilt APK in the emulator.
|
|
||||||
7. Launch the rebuilt app in the emulator.
|
|
||||||
8. Open a controlled demo vault in the emulator.
|
|
||||||
|
|
||||||
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
|
||||||
|
|
||||||
## Required Sequence
|
|
||||||
|
|
||||||
### 1. Commit First
|
|
||||||
|
|
||||||
- Make sure the worktree state intended for shipping is committed before building.
|
|
||||||
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
|
||||||
- Before the commit or push, run the public-repo sanitization checks.
|
|
||||||
|
|
||||||
### 2. Build And Install The Arch Package
|
|
||||||
|
|
||||||
From the repo root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make archlinux-pkgbuild
|
|
||||||
cd packaging/archlinux/keepassgo-git
|
|
||||||
makepkg -si --noconfirm
|
|
||||||
```
|
|
||||||
|
|
||||||
The installed package version must correspond to the committed source, not a dirty worktree.
|
|
||||||
|
|
||||||
### 3. Build The APK
|
|
||||||
|
|
||||||
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
|
||||||
```
|
|
||||||
|
|
||||||
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
|
||||||
|
|
||||||
### 4. Zip The APK
|
|
||||||
|
|
||||||
- Create the ZIP under the globally required temporary secret-safe directory.
|
|
||||||
- Use a name that includes the commit, for example:
|
|
||||||
`keepassgo-<shortsha>-apk.zip`
|
|
||||||
|
|
||||||
### 5. Upload To Nextcloud
|
|
||||||
|
|
||||||
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
|
||||||
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
|
||||||
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
|
||||||
- Ensure that directory exists with mode `700`.
|
|
||||||
- Create secret temp files with mode `600`.
|
|
||||||
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
|
||||||
|
|
||||||
### 6. Emulator Install And Launch
|
|
||||||
|
|
||||||
- Reuse the existing emulator session if one is already running.
|
|
||||||
- Install with replacement:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
adb install -r build/keepassgo.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
- Launch KeePassGO and confirm it is focused.
|
|
||||||
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
|
||||||
|
|
||||||
### 7. Open A Controlled Demo Vault
|
|
||||||
|
|
||||||
- Do not rely on the user's real vault for this step.
|
|
||||||
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
|
||||||
- Open it in the emulator before closing out `ship it`.
|
|
||||||
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
|
||||||
|
|
||||||
## Closeout Requirements
|
|
||||||
|
|
||||||
When reporting back after `ship it`, include:
|
|
||||||
- the commit that was shipped
|
|
||||||
- the installed Arch package version
|
|
||||||
- the APK path
|
|
||||||
- the uploaded ZIP URL
|
|
||||||
- confirmation that the emulator app was launched
|
|
||||||
- confirmation that the controlled demo vault was opened
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Keep this workflow specific to KeePassGO.
|
|
||||||
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
|
||||||
- Do not use `rm -rf`.
|
|
||||||
- Do not use `rm -f`.
|
|
||||||
@@ -128,7 +128,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
go build -ldflags "-X main.appVersion=${app_version}" -o "${out}" .
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
*.apk
|
*.apk
|
||||||
/keepassgo
|
keepassgo
|
||||||
android/keepassgo-android.jar
|
|
||||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||||
packaging/archlinux/keepassgo-git/pkg/
|
packaging/archlinux/keepassgo-git/pkg/
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- gocyclo
|
|
||||||
- gosimple
|
- gosimple
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 15
|
|
||||||
|
|
||||||
issues:
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- gocyclo
|
|
||||||
|
|||||||
@@ -95,20 +95,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||||
- The stacked phone layout is the current preferred phone direction.
|
- The stacked phone layout is the current preferred phone direction.
|
||||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||||
- Keep the product feeling like the same application on desktop, Android phone,
|
|
||||||
and Android tablet.
|
|
||||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
|
||||||
user's mental model of the workflow.
|
|
||||||
- Use the same action names, the same primary next steps, and the same workflow
|
|
||||||
order across platforms unless there is a hard platform constraint.
|
|
||||||
- Treat workflow prominence and reachability as product behavior, not visual
|
|
||||||
polish. A feature is not parity-complete if it technically exists but is
|
|
||||||
harder to discover or reach on one platform.
|
|
||||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
|
||||||
than platform-specific workflow branches.
|
|
||||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
|
||||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
|
||||||
and consistent with the product theme.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -118,20 +104,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||||
- Treat the vault model as local-first across all platforms:
|
|
||||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
|
||||||
that local vault rather than replacing it as the primary object.
|
|
||||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
|
||||||
Android tablet:
|
|
||||||
shared remote configuration belongs in the vault,
|
|
||||||
cross-platform workflow stays the same,
|
|
||||||
and only Android's initial KDBX share/import transport may differ.
|
|
||||||
- Do not persist remote credentials in plaintext app-local state.
|
|
||||||
Keep only non-secret binding metadata outside the vault.
|
|
||||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
|
||||||
design:
|
|
||||||
opening or creating the local vault is the main workflow,
|
|
||||||
and remote setup, remote settings, and remote use attach to that vault.
|
|
||||||
|
|
||||||
## Delivery Discipline
|
## Delivery Discipline
|
||||||
|
|
||||||
@@ -142,14 +114,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
implement the minimum code to satisfy them,
|
implement the minimum code to satisfy them,
|
||||||
verify with `go test ./...` and relevant lint checks,
|
verify with `go test ./...` and relevant lint checks,
|
||||||
and commit each completed behavior.
|
and commit each completed behavior.
|
||||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
|
||||||
just feature or label parity.
|
|
||||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
|
||||||
assert the same conceptual next step across desktop, phone, and tablet
|
|
||||||
layouts.
|
|
||||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
|
||||||
emulator or device for the exact flow being changed. Do not count “the same
|
|
||||||
buttons exist somewhere on screen” as sufficient validation.
|
|
||||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||||
- If blocked, state the blocker concretely and stop only at that point.
|
- If blocked, state the blocker concretely and stop only at that point.
|
||||||
|
|
||||||
@@ -158,13 +122,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Plan for direct KDBX support.
|
- Plan for direct KDBX support.
|
||||||
- Plan for direct WebDAV-based workflows.
|
- Plan for direct WebDAV-based workflows.
|
||||||
- Avoid adding npm-based or browser-stack dependencies.
|
- Avoid adding npm-based or browser-stack dependencies.
|
||||||
- Keep remote configuration and synchronization local-first:
|
|
||||||
the app should maintain a live local KDBX cache even when using a remote
|
|
||||||
store, so remote outage does not eliminate vault access.
|
|
||||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
|
||||||
storage.
|
|
||||||
- On Android, use system picker/share mechanisms for local vault import/export
|
|
||||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
||||||
@@ -187,10 +144,6 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible.
|
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible.
|
||||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||||
- For the Arch package, treat
|
|
||||||
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
|
|
||||||
as the source of truth and regenerate `PKGBUILD` from the template before
|
|
||||||
building.
|
|
||||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
- 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.
|
- 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.
|
- Do not commit generated binaries.
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ Installed machine prerequisites expected by this repo:
|
|||||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go tool gogio -target android ./cmd/keepassgo ...
|
go tool gogio -target android ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The Android build uses the branded icon asset at:
|
The Android build uses the branded icon asset at:
|
||||||
|
|
||||||
- `internal/assets/keepassgo-icon.png`
|
- `assets/keepassgo-icon.png`
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ APP_ID ?= org.julianfamily.keepassgo
|
|||||||
APK_OUT ?= build/keepassgo.apk
|
APK_OUT ?= build/keepassgo.apk
|
||||||
APK_VERSION ?= 0.1.0.1
|
APK_VERSION ?= 0.1.0.1
|
||||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
|
||||||
ANDROID_MIN_SDK ?= 28
|
ANDROID_MIN_SDK ?= 28
|
||||||
ANDROID_TARGET_SDK ?= 35
|
ANDROID_TARGET_SDK ?= 35
|
||||||
SIGNKEY ?=
|
SIGNKEY ?=
|
||||||
@@ -47,8 +47,8 @@ apk: android/keepassgo-android.jar
|
|||||||
-version $(APK_VERSION) \
|
-version $(APK_VERSION) \
|
||||||
-minsdk $(ANDROID_MIN_SDK) \
|
-minsdk $(ANDROID_MIN_SDK) \
|
||||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||||
-icon internal/assets/keepassgo-icon.png \
|
-icon assets/keepassgo-icon.png \
|
||||||
./cmd/keepassgo
|
.
|
||||||
|
|
||||||
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
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 -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
|||||||
Desktop build:
|
Desktop build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./cmd/keepassgo
|
go build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
||||||
You can override the version shown in KeePassGO with:
|
You can override the version shown in KeePassGO with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
go build -ldflags "-X main.appVersion=v0.0.1" ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arch Linux Package
|
## Arch Linux Package
|
||||||
@@ -89,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
|||||||
Package:
|
Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
go tool gogio -target android -icon assets/keepassgo-icon.png .
|
||||||
```
|
```
|
||||||
|
|
||||||
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||||
|
|||||||
@@ -11,36 +11,6 @@ The product is not complete until the global exit criteria at the end of this fi
|
|||||||
These items came from a hands-on emulator and desktop walkthrough.
|
These items came from a hands-on emulator and desktop walkthrough.
|
||||||
They should be treated as usability work, not just polish.
|
They should be treated as usability work, not just polish.
|
||||||
|
|
||||||
### Cross-Platform Workflow Parity
|
|
||||||
|
|
||||||
These items are required to keep desktop, Android phone, and Android tablet
|
|
||||||
feeling like the same application rather than three related UIs.
|
|
||||||
|
|
||||||
- Workflow parity:
|
|
||||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
|
||||||
sync, browse entries, and edit entries.
|
|
||||||
- Workflow parity:
|
|
||||||
ensure desktop, phone, and tablet use the same action names and the same
|
|
||||||
primary next steps for those workflows.
|
|
||||||
- Workflow parity:
|
|
||||||
remove or reduce platform-specific workflow exceptions where the same user
|
|
||||||
intent currently takes a different route on different form factors.
|
|
||||||
- Testing:
|
|
||||||
add cross-mode behavior tests that assert workflow order and action
|
|
||||||
prominence, not just label presence.
|
|
||||||
- Testing:
|
|
||||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
|
||||||
action on desktop, phone, and tablet layouts.
|
|
||||||
- Testing:
|
|
||||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
|
||||||
removal are reachable from the same primary affordance family across modes.
|
|
||||||
- Android verification:
|
|
||||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
|
||||||
including with the on-screen keyboard visible.
|
|
||||||
- Android verification:
|
|
||||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
|
||||||
failure, not as acceptable platform variation.
|
|
||||||
|
|
||||||
### Primary Workflow Changes
|
### Primary Workflow Changes
|
||||||
|
|
||||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build android
|
//go:build android
|
||||||
|
|
||||||
package platform
|
package main
|
||||||
|
|
||||||
/*
|
/*
|
||||||
#cgo CFLAGS: -Werror
|
#cgo CFLAGS: -Werror
|
||||||
@@ -71,7 +71,7 @@ func gioJavaVM() *C.JavaVM
|
|||||||
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||||
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||||
|
|
||||||
func NewVaultSharer(goos string) VaultSharer {
|
func newPlatformVaultSharer(goos string) vaultSharer {
|
||||||
return androidVaultSharer{}
|
return androidVaultSharer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !android
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func newPlatformVaultSharer(goos string) vaultSharer {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"git.julianfamily.org/keepassgo/session"
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EventType string
|
type EventType string
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SyncMode string
|
type SyncMode string
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||||
@@ -15,28 +15,28 @@ func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "linuscaldwell-webdav",
|
ID: "tjulian-webdav",
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
Title: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
Path: []string{"Crew", "Internet"},
|
Path: []string{"Crew", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
RemoteProfiles: []vault.RemoteProfile{
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
{
|
{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
binding := RemoteBinding{
|
binding := RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "linuscaldwell-webdav",
|
CredentialEntryID: "tjulian-webdav",
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +47,14 @@ func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
|||||||
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||||
}
|
}
|
||||||
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" {
|
||||||
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got)
|
||||||
}
|
}
|
||||||
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
if got := resolved.Credentials.Username; got != "tjulian" {
|
||||||
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
t.Fatalf("resolved credentials username = %q, want tjulian", got)
|
||||||
}
|
}
|
||||||
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
if got := resolved.Credentials.Password; got != "token-1" {
|
||||||
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
t.Fatalf("resolved credentials password = %q, want token-1", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +63,13 @@ func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
|||||||
|
|
||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
{ID: "tjulian-webdav", Title: "WebDAV Sign-In"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := (RemoteBinding{
|
_, err := (RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "missing-creds",
|
CredentialEntryID: "missing-creds",
|
||||||
}).Resolve(model)
|
}).Resolve(model)
|
||||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
@@ -77,16 +77,16 @@ func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model.RemoteProfiles = []vault.RemoteProfile{{
|
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, err = (RemoteBinding{
|
_, err = (RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "missing-creds",
|
CredentialEntryID: "missing-creds",
|
||||||
}).Resolve(model)
|
}).Resolve(model)
|
||||||
if !errors.Is(err, vault.ErrEntryNotFound) {
|
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
@@ -98,8 +98,8 @@ func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
content, err := json.Marshal(RemoteBinding{
|
content, err := json.Marshal(RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
})
|
})
|
||||||
@@ -108,7 +108,7 @@ func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
text := string(content)
|
text := string(content)
|
||||||
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
for _, disallowed := range []string{"token-1", "password", "username", "baseUrl"} {
|
||||||
if strings.Contains(text, disallowed) {
|
if strings.Contains(text, disallowed) {
|
||||||
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||||
}
|
}
|
||||||
@@ -121,15 +121,15 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T)
|
|||||||
var model vault.Model
|
var model vault.Model
|
||||||
|
|
||||||
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
RemoteProfileName: "Bellagio Vault",
|
RemoteProfileName: "Family Vault",
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
CredentialTitle: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
CredentialPath: []string{"Crew", "Internet"},
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
})
|
})
|
||||||
@@ -148,17 +148,17 @@ func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
}
|
}
|
||||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
if credentials.Username != "tjulian" || credentials.Password != "token-1" {
|
||||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
t.Fatalf("stored credential entry = %#v, want tjulian/token-1", credentials)
|
||||||
}
|
}
|
||||||
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
if binding.LocalVaultPath != "/tmp/family.kdbx" {
|
||||||
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath)
|
||||||
}
|
}
|
||||||
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||||
t.Fatalf("binding = %#v, want only vault references", binding)
|
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,31 +173,31 @@ func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "missing_local_vault_path",
|
name: "missing_local_vault_path",
|
||||||
input: RemoteBindingInput{
|
input: RemoteBindingInput{
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing_remote_base_url",
|
name: "missing_remote_base_url",
|
||||||
input: RemoteBindingInput{
|
input: RemoteBindingInput{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing_credential_entry_id",
|
name: "missing_credential_entry_id",
|
||||||
input: RemoteBindingInput{
|
input: RemoteBindingInput{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -219,22 +219,22 @@ func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T)
|
|||||||
model := vault.Model{
|
model := vault.Model{
|
||||||
Entries: []vault.Entry{{
|
Entries: []vault.Entry{{
|
||||||
ID: "remote-creds-1",
|
ID: "remote-creds-1",
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
Title: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
}},
|
}},
|
||||||
RemoteProfiles: []vault.RemoteProfile{{
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := RemoveRemoteBinding(&model, RemoteBinding{
|
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Section string
|
type Section string
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||||
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
|||||||
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -172,18 +172,18 @@ func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
RemoteProfiles: []vault.RemoteProfile{
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
{
|
{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "archive-webdav",
|
ID: "archive-webdav",
|
||||||
Name: "Archive Vault",
|
Name: "Archive Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/archive.kdbx",
|
Path: "files/family/archive.kdbx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -197,7 +197,7 @@ func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
|||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
|
if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" {
|
||||||
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,7 @@ func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
{ID: "cred-3", Title: "Beta Sign-In", Username: "buser", Path: []string{"Crew", "Home"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -238,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -252,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -415,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
RecycleBin: []vault.Entry{
|
RecycleBin: []vault.Entry{
|
||||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -484,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -497,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
@@ -684,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
if len(got) != 1 || got[0].Password != "token-1" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,19 +1037,19 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "remote-creds-1",
|
ID: "remote-creds-1",
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
Title: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
Path: []string{"Crew", "Internet"},
|
Path: []string{"Crew", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
RemoteProfiles: []vault.RemoteProfile{
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
{
|
{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1062,8 +1062,8 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
@@ -1074,14 +1074,14 @@ func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
|||||||
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||||
}
|
}
|
||||||
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
if got := sess.remoteClient.Username; got != "tjulian" {
|
||||||
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
t.Fatalf("remote client username = %q, want tjulian", got)
|
||||||
}
|
}
|
||||||
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
if got := sess.remoteClient.Password; got != "token-1" {
|
||||||
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
t.Fatalf("remote client password = %q, want token-1", got)
|
||||||
}
|
}
|
||||||
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
|
if got := sess.remotePath; got != "files/family/keepass.kdbx" {
|
||||||
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
|
t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
|
||||||
}
|
}
|
||||||
if len(state.CurrentPath) != 0 {
|
if len(state.CurrentPath) != 0 {
|
||||||
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
@@ -1101,7 +1101,7 @@ func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
|||||||
state := State{Session: sess}
|
state := State{Session: sess}
|
||||||
|
|
||||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "missing-profile",
|
RemoteProfileID: "missing-profile",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
@@ -1117,15 +1117,15 @@ func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.
|
|||||||
state := State{Session: sess}
|
state := State{Session: sess}
|
||||||
|
|
||||||
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
RemoteProfileName: "Bellagio Vault",
|
RemoteProfileName: "Family Vault",
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
CredentialTitle: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
CredentialPath: []string{"Crew", "Internet"},
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
})
|
})
|
||||||
@@ -1136,8 +1136,8 @@ func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.
|
|||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||||
}
|
}
|
||||||
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
|
if got := binding.RemoteProfileID; got != "family-webdav" {
|
||||||
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
|
t.Fatalf("binding.RemoteProfileID = %q, want family-webdav", got)
|
||||||
}
|
}
|
||||||
if got := len(sess.model.RemoteProfiles); got != 1 {
|
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||||
@@ -1146,8 +1146,8 @@ func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
}
|
}
|
||||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
if credentials.Username != "tjulian" || credentials.Password != "token-1" {
|
||||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
t.Fatalf("stored credential entry = %#v, want tjulian/token-1", credentials)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,12 +1156,12 @@ func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
|||||||
|
|
||||||
state := State{Session: stubSession{model: vault.Model{}}}
|
state := State{Session: stubSession{model: vault.Model{}}}
|
||||||
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
RemotePath: "files/bellagio/keepass.kdbx",
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||||
@@ -1174,23 +1174,23 @@ func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
|||||||
sess := &mutableStubSession{model: vault.Model{
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
Entries: []vault.Entry{{
|
Entries: []vault.Entry{{
|
||||||
ID: "remote-creds-1",
|
ID: "remote-creds-1",
|
||||||
Title: "Bellagio WebDAV Sign-In",
|
Title: "WebDAV Sign-In",
|
||||||
Username: "linuscaldwell",
|
Username: "tjulian",
|
||||||
Password: "bellagio-pass-1",
|
Password: "token-1",
|
||||||
}},
|
}},
|
||||||
RemoteProfiles: []vault.RemoteProfile{{
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
ID: "bellagio-webdav",
|
ID: "family-webdav",
|
||||||
Name: "Bellagio Vault",
|
Name: "Family Vault",
|
||||||
Backend: vault.RemoteBackendWebDAV,
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
Path: "files/bellagio/keepass.kdbx",
|
Path: "files/family/keepass.kdbx",
|
||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
state := State{Session: sess}
|
state := State{Session: sess}
|
||||||
|
|
||||||
err := state.RemoveRemoteBinding(RemoteBinding{
|
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
RemoteProfileID: "bellagio-webdav",
|
RemoteProfileID: "family-webdav",
|
||||||
CredentialEntryID: "remote-creds-1",
|
CredentialEntryID: "remote-creds-1",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1393,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
|||||||
SelectedEntryID: "vault-console",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||||
|
|
||||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||||
}
|
}
|
||||||
if got := state.SelectedEntryID; got != "" {
|
if got := state.SelectedEntryID; got != "" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||||
@@ -1429,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1452,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||||
@@ -1526,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||||
@@ -1544,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
|||||||
SelectedEntryID: "bellagio",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
@@ -1756,7 +1756,7 @@ func testVaultModel() vault.Model {
|
|||||||
return vault.Model{
|
return vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||||
@@ -13,10 +13,10 @@ const (
|
|||||||
DefaultAppID = "org.julianfamily.keepassgo"
|
DefaultAppID = "org.julianfamily.keepassgo"
|
||||||
DefaultAPKOut = "build/keepassgo.apk"
|
DefaultAPKOut = "build/keepassgo.apk"
|
||||||
DefaultVersion = "0.1.0.1"
|
DefaultVersion = "0.1.0.1"
|
||||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
DefaultLdflags = "-X main.appVersion=dev"
|
||||||
DefaultMinSDK = "28"
|
DefaultMinSDK = "28"
|
||||||
DefaultTargetSDK = "35"
|
DefaultTargetSDK = "35"
|
||||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -58,7 +58,7 @@ func (c Config) GogioArgs() []string {
|
|||||||
"-minsdk", c.MinSDK,
|
"-minsdk", c.MinSDK,
|
||||||
"-targetsdk", c.TargetSDK,
|
"-targetsdk", c.TargetSDK,
|
||||||
"-icon", c.IconPath,
|
"-icon", c.IconPath,
|
||||||
"./cmd/keepassgo",
|
".",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
|||||||
"-minsdk", DefaultMinSDK,
|
"-minsdk", DefaultMinSDK,
|
||||||
"-targetsdk", DefaultTargetSDK,
|
"-targetsdk", DefaultTargetSDK,
|
||||||
"-icon", DefaultIconPath,
|
"-icon", DefaultIconPath,
|
||||||
"./cmd/keepassgo",
|
".",
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
systemclipboard "github.com/atotto/clipboard"
|
systemclipboard "github.com/atotto/clipboard"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package platform
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,27 +8,23 @@ import (
|
|||||||
gioclipboard "gioui.org/io/clipboard"
|
gioclipboard "gioui.org/io/clipboard"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
|
||||||
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
|
appclipboard "git.julianfamily.org/keepassgo/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VaultSharer interface {
|
|
||||||
ShareVault(path, title string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type clipboardCommandWriter struct {
|
type clipboardCommandWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
pending []string
|
pending []string
|
||||||
invalidate func()
|
invalidate func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||||
if strings.EqualFold(goos, "android") {
|
if strings.EqualFold(goos, "android") {
|
||||||
return &clipboardCommandWriter{invalidate: invalidate}
|
return &clipboardCommandWriter{invalidate: invalidate}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package platform
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -8,17 +8,17 @@ import (
|
|||||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
writer := NewClipboardWriter("android", nil)
|
writer := newPlatformClipboardWriter("android", nil)
|
||||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
||||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
appui.Main()
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ module git.julianfamily.org/keepassgo
|
|||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.8.0
|
gioui.org v0.8.0
|
||||||
gioui.org/x v0.8.0
|
gioui.org/x v0.8.0
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
|||||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
|
||||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
|
||||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuditQuickFilter struct {
|
|
||||||
Label string
|
|
||||||
Query string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Operations() []apitokens.Operation {
|
|
||||||
return []apitokens.Operation{
|
|
||||||
apitokens.OperationListEntries,
|
|
||||||
apitokens.OperationListGroups,
|
|
||||||
apitokens.OperationReadEntry,
|
|
||||||
apitokens.OperationCopyPassword,
|
|
||||||
apitokens.OperationCopyUsername,
|
|
||||||
apitokens.OperationCopyURL,
|
|
||||||
apitokens.OperationMutateEntry,
|
|
||||||
apitokens.OperationMutateGroup,
|
|
||||||
apitokens.OperationManageVault,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditDecisionLabel(eventType apiaudit.EventType) string {
|
|
||||||
switch eventType {
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
return "Requested"
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
return "Allowed"
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
return "Denied"
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
return "Canceled"
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
return "Timed Out"
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
return "Auth Rejected"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditOperationLabel(operation apitokens.Operation) string {
|
|
||||||
if strings.TrimSpace(string(operation)) == "" {
|
|
||||||
return "Other"
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(string(operation), "_", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func CompactAuditFilterLabel(label string) string {
|
|
||||||
label = strings.TrimSpace(label)
|
|
||||||
if len(label) <= 22 {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return label[:19] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuditEventSearchTerms(event apiaudit.Event) string {
|
|
||||||
parts := []string{
|
|
||||||
string(event.Type),
|
|
||||||
AuditDecisionLabel(event.Type),
|
|
||||||
event.TokenName,
|
|
||||||
event.ClientName,
|
|
||||||
string(event.Operation),
|
|
||||||
AuditOperationLabel(event.Operation),
|
|
||||||
strings.Join(event.Resource.Path, " / "),
|
|
||||||
event.Resource.EntryID,
|
|
||||||
event.Message,
|
|
||||||
}
|
|
||||||
switch event.Type {
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
parts = append(parts, "allow approved")
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
parts = append(parts, "deny denied")
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
parts = append(parts, "prompt requested")
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
parts = append(parts, "cancel canceled")
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
parts = append(parts, "timeout timed out")
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
parts = append(parts, "rejected unauthorized")
|
|
||||||
}
|
|
||||||
return strings.ToLower(strings.Join(parts, " "))
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
type Mode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModeLocked Mode = "locked"
|
|
||||||
ModeStatic Mode = "static"
|
|
||||||
ModeEmpty Mode = "empty"
|
|
||||||
ModeEditor Mode = "editor"
|
|
||||||
ModeView Mode = "view"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
|
||||||
switch {
|
|
||||||
case isLocked:
|
|
||||||
return ModeLocked
|
|
||||||
case hasStaticPanel:
|
|
||||||
return ModeStatic
|
|
||||||
case !hasSelectedEntry && !editing:
|
|
||||||
return ModeEmpty
|
|
||||||
case editing:
|
|
||||||
return ModeEditor
|
|
||||||
default:
|
|
||||||
return ModeView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package detail
|
|
||||||
|
|
||||||
type EmptyState struct {
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
type VaultSummary struct {
|
|
||||||
Title string
|
|
||||||
Detail string
|
|
||||||
Context string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttachmentItem struct {
|
|
||||||
Name string
|
|
||||||
Size int
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package editor
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
type Field string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FieldID Field = "id"
|
|
||||||
FieldTitle Field = "title"
|
|
||||||
FieldUsername Field = "username"
|
|
||||||
FieldPassword Field = "password"
|
|
||||||
FieldURL Field = "url"
|
|
||||||
FieldPath Field = "path"
|
|
||||||
FieldTags Field = "tags"
|
|
||||||
FieldPasswordProfile Field = "password-profile"
|
|
||||||
FieldNotes Field = "notes"
|
|
||||||
FieldFields Field = "fields"
|
|
||||||
FieldHistoryIndex Field = "history-index"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Label(field Field) string {
|
|
||||||
switch field {
|
|
||||||
case FieldID:
|
|
||||||
return "ID"
|
|
||||||
case FieldTitle:
|
|
||||||
return "Title"
|
|
||||||
case FieldUsername:
|
|
||||||
return "Username"
|
|
||||||
case FieldPassword:
|
|
||||||
return "Password"
|
|
||||||
case FieldURL:
|
|
||||||
return "URL"
|
|
||||||
case FieldPath:
|
|
||||||
return "Path"
|
|
||||||
case FieldTags:
|
|
||||||
return "Tags"
|
|
||||||
case FieldPasswordProfile:
|
|
||||||
return "Password Profile"
|
|
||||||
case FieldNotes:
|
|
||||||
return "Notes"
|
|
||||||
case FieldFields:
|
|
||||||
return "Custom Fields"
|
|
||||||
case FieldHistoryIndex:
|
|
||||||
return "History Index"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(field), "-", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FocusOrder() []Field {
|
|
||||||
return []Field{
|
|
||||||
FieldID,
|
|
||||||
FieldTitle,
|
|
||||||
FieldUsername,
|
|
||||||
FieldPassword,
|
|
||||||
FieldURL,
|
|
||||||
FieldPath,
|
|
||||||
FieldTags,
|
|
||||||
FieldPasswordProfile,
|
|
||||||
FieldNotes,
|
|
||||||
FieldFields,
|
|
||||||
FieldHistoryIndex,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
||||||
return u.headerActions(gtx)
|
|
||||||
}
|
|
||||||
if u.shouldShowDesktopWorkingHeader() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.brandMark(gtx, 196, 56)
|
|
||||||
}),
|
|
||||||
layout.Flexed(1, u.headerActions),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
rowDims := cluster.layout(gtx, u)
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
cluster.prepareCompactMenus(gtx, u)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
|
||||||
}
|
|
||||||
return rowDims
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerActionCluster struct {
|
|
||||||
Metrics headerlayout.ActionMetrics
|
|
||||||
SyncMenu layout.Widget
|
|
||||||
MainMenu layout.Widget
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
|
||||||
|
|
||||||
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
|
||||||
|
|
||||||
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
|
||||||
cluster := headerActionCluster{
|
|
||||||
SyncMenu: u.syncMenuWidget(),
|
|
||||||
MainMenu: u.mainMenuWidget(),
|
|
||||||
}
|
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
|
||||||
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
|
||||||
if !u.usesCompactViewport() {
|
|
||||||
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
|
||||||
}
|
|
||||||
return cluster
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
|
|
||||||
rowOps := op.Record(gtx.Ops)
|
|
||||||
c.Metrics.RowDims = c.layoutRow(gtx, u)
|
|
||||||
rowCall := rowOps.Stop()
|
|
||||||
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
|
|
||||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
rowCall.Add(gtx.Ops)
|
|
||||||
return c.Metrics.RowDims
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) activeMenu() layout.Widget {
|
|
||||||
switch {
|
|
||||||
case c.ShowSyncMenu():
|
|
||||||
return c.SyncMenu
|
|
||||||
case c.ShowMainMenu():
|
|
||||||
return c.MainMenu
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
|
||||||
return c.Metrics.SyncDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
c.Metrics.LockDims = btn.Layout(gtx)
|
|
||||||
return c.Metrics.LockDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
|
||||||
return c.Metrics.MainDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
|
|
||||||
compactSurface := headerlayout.DropdownSurface{
|
|
||||||
ContainerWidth: gtx.Constraints.Max.X,
|
|
||||||
LeftInset: u.frameInsetPx,
|
|
||||||
TopInset: u.frameInsetPx,
|
|
||||||
}
|
|
||||||
if c.ShowSyncMenu() {
|
|
||||||
u.phoneSyncMenuVisible = true
|
|
||||||
u.maybeLogHeaderMenuToggle("sync-visible", true)
|
|
||||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
|
|
||||||
u.phoneSyncMenuOrigin = placement.Origin
|
|
||||||
u.phoneSyncMenuSize = placement.Size
|
|
||||||
u.phoneSyncMenuCall = menuCall
|
|
||||||
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
|
|
||||||
}
|
|
||||||
if c.ShowMainMenu() {
|
|
||||||
u.phoneMainMenuVisible = true
|
|
||||||
u.maybeLogHeaderMenuToggle("main-visible", true)
|
|
||||||
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
|
|
||||||
u.phoneMainMenuOrigin = placement.Origin
|
|
||||||
u.phoneMainMenuSize = placement.Size
|
|
||||||
u.phoneMainMenuCall = menuCall
|
|
||||||
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
|
|
||||||
menu := c.activeMenu()
|
|
||||||
if menu == nil {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
fullWidthGTX := gtx
|
|
||||||
fullWidthGTX.Constraints.Min = image.Point{}
|
|
||||||
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
|
|
||||||
dims := layout.E.Layout(fullWidthGTX, menu)
|
|
||||||
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerButtonBounds struct {
|
|
||||||
SyncPrimary image.Rectangle
|
|
||||||
SyncToggle image.Rectangle
|
|
||||||
Lock image.Rectangle
|
|
||||||
MainMenu image.Rectangle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
|
|
||||||
return headerButtonBounds{
|
|
||||||
SyncPrimary: bounds.SyncPrimary.Add(origin),
|
|
||||||
SyncToggle: bounds.SyncToggle.Add(origin),
|
|
||||||
Lock: bounds.Lock.Add(origin),
|
|
||||||
MainMenu: bounds.MainMenu.Add(origin),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b headerButtonBounds) logLine(mode string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
|
|
||||||
mode,
|
|
||||||
formatHeaderRect(b.SyncPrimary),
|
|
||||||
formatHeaderRect(b.SyncToggle),
|
|
||||||
formatHeaderRect(b.Lock),
|
|
||||||
formatHeaderRect(b.MainMenu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatHeaderRect(rect image.Rectangle) string {
|
|
||||||
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) topRightActionOrder() []string {
|
|
||||||
if u.isVaultLocked() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{"Sync", "Lock", "Menu"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
if u.syncMenuVisibleOnPhone() {
|
|
||||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
|
||||||
defer stack.Pop()
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if u.mainMenuVisibleOnPhone() {
|
|
||||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
|
||||||
defer stack.Pop()
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
cluster := u.newHeaderActionCluster(gtx)
|
|
||||||
dims := cluster.layoutMenuRow(gtx)
|
|
||||||
if dims.Size.Y == 0 {
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
|
||||||
|
|
||||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.usesCompactViewport() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
width := gtx.Dp(unit.Dp(widthDP))
|
|
||||||
height := gtx.Dp(unit.Dp(heightDP))
|
|
||||||
if width > gtx.Constraints.Max.X {
|
|
||||||
width = gtx.Constraints.Max.X
|
|
||||||
}
|
|
||||||
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
|
||||||
height = gtx.Constraints.Max.Y
|
|
||||||
}
|
|
||||||
img := widget.Image{
|
|
||||||
Src: src,
|
|
||||||
Fit: widget.Contain,
|
|
||||||
Position: layout.W,
|
|
||||||
Scale: 1.0 / gtx.Metric.PxPerDp,
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min = image.Point{}
|
|
||||||
gtx.Constraints.Max = image.Pt(width, height)
|
|
||||||
return img.Layout(gtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuWidget() layout.Widget {
|
|
||||||
if !u.mainMenuOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return u.mainMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
|
||||||
icon := u.menuIcon
|
|
||||||
if icon == nil {
|
|
||||||
icon = u.settingsIcon
|
|
||||||
}
|
|
||||||
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|
||||||
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
||||||
return headerlayout.MenuActionWidth(gtx, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
||||||
return headerlayout.RightAlignedAction(gtx, width, child)
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AnchoredMenuX(triggerWidth, menuWidth int) int {
|
|
||||||
return triggerWidth - menuWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
|
|
||||||
x := rowOriginX + triggerRightX - menuWidth
|
|
||||||
if x < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if x+menuWidth > containerWidth {
|
|
||||||
return max(0, containerWidth-menuWidth)
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownAnchor struct {
|
|
||||||
TriggerRightX int
|
|
||||||
TriggerBottomY int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a DropdownAnchor) Point() image.Point {
|
|
||||||
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownSurface struct {
|
|
||||||
ContainerWidth int
|
|
||||||
LeftInset int
|
|
||||||
TopInset int
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownPlacement struct {
|
|
||||||
Anchor DropdownAnchor
|
|
||||||
Origin image.Point
|
|
||||||
Size image.Point
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
|
||||||
menuGTX := gtx
|
|
||||||
menuGTX.Constraints.Min = image.Point{}
|
|
||||||
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
|
|
||||||
return menuGTX
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
|
|
||||||
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
|
|
||||||
y := s.TopInset + anchor.TriggerBottomY
|
|
||||||
return image.Pt(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
|
|
||||||
menuGTX := s.MenuConstraints(gtx)
|
|
||||||
menuOps := op.Record(gtx.Ops)
|
|
||||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
|
||||||
menuCall := menuOps.Stop()
|
|
||||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
|
||||||
return DropdownPlacement{
|
|
||||||
Anchor: anchor,
|
|
||||||
Origin: menuOrigin,
|
|
||||||
Size: menuDims.Size,
|
|
||||||
}, menuCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
|
||||||
placement, menuCall := s.Place(gtx, anchor, menu)
|
|
||||||
stack := op.Offset(placement.Origin).Push(gtx.Ops)
|
|
||||||
menuCall.Add(gtx.Ops)
|
|
||||||
stack.Pop()
|
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionMetrics struct {
|
|
||||||
RowOriginX int
|
|
||||||
Spacing int
|
|
||||||
SyncInnerSpacing int
|
|
||||||
RowDims layout.Dimensions
|
|
||||||
SyncDims layout.Dimensions
|
|
||||||
SyncPrimaryDims layout.Dimensions
|
|
||||||
SyncToggleDims layout.Dimensions
|
|
||||||
LockDims layout.Dimensions
|
|
||||||
MainDims layout.Dimensions
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
|
||||||
return DropdownAnchor{
|
|
||||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
|
||||||
TriggerBottomY: m.RowDims.Size.Y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
|
||||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
|
||||||
return DropdownAnchor{
|
|
||||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
|
||||||
TriggerBottomY: m.RowDims.Size.Y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionBounds struct {
|
|
||||||
SyncPrimary image.Rectangle
|
|
||||||
SyncToggle image.Rectangle
|
|
||||||
Lock image.Rectangle
|
|
||||||
MainMenu image.Rectangle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ActionMetrics) Bounds() ActionBounds {
|
|
||||||
top := 0
|
|
||||||
syncLeft := m.RowOriginX
|
|
||||||
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
|
|
||||||
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
|
|
||||||
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
|
|
||||||
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
|
|
||||||
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
|
|
||||||
mainLeft := lock.Max.X + m.Spacing
|
|
||||||
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
|
|
||||||
return ActionBounds{
|
|
||||||
SyncPrimary: syncPrimary,
|
|
||||||
SyncToggle: syncToggle,
|
|
||||||
Lock: lock,
|
|
||||||
MainMenu: mainMenu,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
|
||||||
macro := op.Record(gtx.Ops)
|
|
||||||
contentDims := w(measureGTX)
|
|
||||||
_ = macro.Stop()
|
|
||||||
if logger != nil {
|
|
||||||
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
|
|
||||||
}
|
|
||||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
|
||||||
maxWidth := gtx.Constraints.Max.X
|
|
||||||
if maxWidth > 0 && width > maxWidth {
|
|
||||||
width = maxWidth
|
|
||||||
}
|
|
||||||
if width > 0 {
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
}
|
|
||||||
dims := card(gtx, w)
|
|
||||||
if logger != nil {
|
|
||||||
logger("intrinsic-card", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
|
|
||||||
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
||||||
width := 0
|
|
||||||
for _, row := range rows {
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
macro := op.Record(gtx.Ops)
|
|
||||||
dims := row(measureGTX)
|
|
||||||
_ = macro.Stop()
|
|
||||||
if dims.Size.X > width {
|
|
||||||
width = dims.Size.X
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
||||||
if width <= 0 {
|
|
||||||
return child(gtx)
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
return layout.E.Layout(gtx, child)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package header
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
|
||||||
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
|
||||||
if logger != nil {
|
|
||||||
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
|
|
||||||
}
|
|
||||||
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
|
|
||||||
for i, row := range rows {
|
|
||||||
if i > 0 {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
current := row
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
||||||
if logger != nil {
|
|
||||||
logger("rows", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}, card, logger)
|
|
||||||
if logger != nil {
|
|
||||||
logger("card", gtx.Constraints, dims)
|
|
||||||
}
|
|
||||||
return dims
|
|
||||||
}
|
|
||||||
|
|
||||||
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
|
|
||||||
btn := material.IconButton(theme, click, icon, "Menu")
|
|
||||||
if open {
|
|
||||||
btn.Background = accentColor
|
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = selectedColor
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
|
||||||
btn.Size = unit.Dp(18)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
|
|
||||||
spacing := unit.Dp(4)
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
spacing = unit.Dp(3)
|
|
||||||
}
|
|
||||||
var primaryDims layout.Dimensions
|
|
||||||
var toggleDims layout.Dimensions
|
|
||||||
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
|
||||||
return primaryDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
toggleDims = u.syncMenuToggle(gtx)
|
|
||||||
return toggleDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return groupDims, primaryDims, toggleDims
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
|
||||||
if u.syncMenuOpen {
|
|
||||||
btn.Background = accentColor
|
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
|
||||||
btn.Size = unit.Dp(18)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
btn.Size = unit.Dp(16)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(7))
|
|
||||||
}
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuWidget() layout.Widget {
|
|
||||||
if !u.syncMenuOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return u.syncMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
model := u.buildSyncMenuModel()
|
|
||||||
profiles := u.availableRemoteProfiles()
|
|
||||||
credentials := u.availableRemoteCredentialEntries()
|
|
||||||
if len(u.vaultRemoteProfileClicks) < len(profiles) {
|
|
||||||
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
|
|
||||||
}
|
|
||||||
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
|
|
||||||
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
|
|
||||||
}
|
|
||||||
actionRows := u.syncMenuActionRows(model)
|
|
||||||
actionWidth := menuActionWidth(gtx, actionRows)
|
|
||||||
menu := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
|
|
||||||
if reserveWidth <= 0 {
|
|
||||||
return menu(gtx)
|
|
||||||
}
|
|
||||||
return layout.Flex{}.Layout(gtx,
|
|
||||||
layout.Rigid(menu),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
|
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
spacing = gtx.Dp(unit.Dp(8))
|
|
||||||
}
|
|
||||||
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
|
|
||||||
lockOps := op.Record(gtx.Ops)
|
|
||||||
lockDims := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}(measureGTX)
|
|
||||||
_ = lockOps.Stop()
|
|
||||||
|
|
||||||
menuOps := op.Record(gtx.Ops)
|
|
||||||
menuDims := u.mainMenuButtonGroup(measureGTX)
|
|
||||||
_ = menuOps.Stop()
|
|
||||||
|
|
||||||
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if model.ShowShare {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSetupShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowDirectRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if model.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
|
||||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
|
||||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
|
||||||
if model.ShowSaveCurrentBinding {
|
|
||||||
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
|
||||||
rows := []layout.FlexChild{}
|
|
||||||
if model.ShowShare {
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
|
|
||||||
if model.ShowRemoteSyncSetupShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowDirectRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
|
|
||||||
}
|
|
||||||
if model.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
|
|
||||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, click, label)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
|
||||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rows := []layout.FlexChild{
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
}
|
|
||||||
if !model.ShowSelectors {
|
|
||||||
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
|
|
||||||
} else {
|
|
||||||
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
|
|
||||||
}
|
|
||||||
if _, ok := u.selectedVaultRemoteProfile(); ok {
|
|
||||||
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
|
|
||||||
rows = append(rows,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
|
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
|
||||||
summary := model.SavedBindingSummary
|
|
||||||
if !summary.OK {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
|
|
||||||
return []layout.FlexChild{
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
|
||||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
|
||||||
for i, profile := range profiles {
|
|
||||||
i := i
|
|
||||||
profile := profile
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
selected := u.selectedVaultRemoteProfileID == profile.ID
|
|
||||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
for i, entry := range credentials {
|
|
||||||
i := i
|
|
||||||
entry := entry
|
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
|
|
||||||
label := entry.Title
|
|
||||||
if entry.Username != "" {
|
|
||||||
label += " · " + entry.Username
|
|
||||||
}
|
|
||||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
|
|
||||||
model := syncmodel.MenuModel{
|
|
||||||
HasOpenVault: u.hasOpenVault(),
|
|
||||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
|
||||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
|
||||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
|
||||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
RemotePassword: u.remotePassword.Text(),
|
|
||||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
|
||||||
}
|
|
||||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
|
||||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
|
||||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
|
|
||||||
profile, ok := u.selectedVaultRemoteProfile()
|
|
||||||
if !ok {
|
|
||||||
return syncmodel.MenuBindingSummary{}
|
|
||||||
}
|
|
||||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
|
||||||
if !ok {
|
|
||||||
return syncmodel.MenuBindingSummary{}
|
|
||||||
}
|
|
||||||
credentialLabel := entry.Title
|
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
|
||||||
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
|
||||||
}
|
|
||||||
syncLabel := "Sync manually when you choose Use Remote Sync."
|
|
||||||
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
|
||||||
syncLabel = "Syncs automatically on open and save."
|
|
||||||
}
|
|
||||||
return syncmodel.MenuBindingSummary{
|
|
||||||
ProfileLabel: profile.Name,
|
|
||||||
CredentialLabel: credentialLabel,
|
|
||||||
SyncLabel: syncLabel,
|
|
||||||
OK: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package lifecycle
|
|
||||||
|
|
||||||
type OpenIntent string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OpenIntentNone OpenIntent = ""
|
|
||||||
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
|
||||||
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
|
||||||
)
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/x/explorer"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) createVaultAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
|
||||||
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
|
||||||
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.CreateVault(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.lifecycleMode == "local" {
|
|
||||||
u.selectedVaultRemoteProfileID = ""
|
|
||||||
u.selectedVaultRemoteCredentialEntryID = ""
|
|
||||||
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
|
||||||
u.remoteBaseURL.SetText("")
|
|
||||||
u.remotePath.SetText("")
|
|
||||||
u.remoteUsername.SetText("")
|
|
||||||
u.remotePassword.SetText("")
|
|
||||||
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.vaultPath.SetText(u.saveAsTargetPath())
|
|
||||||
u.noteRecentVault(u.saveAsTargetPath())
|
|
||||||
}
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openVaultAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.OpenVault(path, key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.restoreRecentVaultGroup(path)
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
|
||||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
|
||||||
}
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
u.applyPendingLifecycleOpenIntent()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startOpenVaultAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("open vault", u.openVaultAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.lastLifecycleAction = "open vault"
|
|
||||||
u.runBackgroundAction("open vault", func() (func() error, error) {
|
|
||||||
prepared, err := session.PrepareLocalOpen(path, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedLocalOpen(prepared)
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.restoreRecentVaultGroup(path)
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
|
||||||
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
|
||||||
}
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
u.applyPendingLifecycleOpenIntent()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
|
||||||
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return "Open Vault And Set Up Remote Sync"
|
|
||||||
}
|
|
||||||
if hasBoundRecentRemote(u.recentRemotes, path) {
|
|
||||||
return "Open Vault And Open Remote Sync Settings"
|
|
||||||
}
|
|
||||||
return "Open Vault And Set Up Remote Sync"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
switch {
|
|
||||||
case path == "":
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
|
||||||
case hasBoundRecentRemote(u.recentRemotes, path):
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
|
||||||
default:
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
|
||||||
}
|
|
||||||
u.startOpenVaultAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) applyPendingLifecycleOpenIntent() {
|
|
||||||
intent := u.pendingLifecycleOpenIntent
|
|
||||||
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
|
||||||
switch intent {
|
|
||||||
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
|
||||||
u.openRemoteSyncSetupDialog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAction() error {
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAsAction() error {
|
|
||||||
path := u.saveAsTargetPath()
|
|
||||||
if err := u.state.SaveAs(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.vaultPath.SetText(path)
|
|
||||||
u.noteRecentVault(path)
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openRemoteAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
|
||||||
return err
|
|
||||||
} else if ok {
|
|
||||||
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentRemote(
|
|
||||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
strings.TrimSpace(u.remotePath.Text()),
|
|
||||||
)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startOpenRemoteAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("open remote vault", u.openRemoteAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}
|
|
||||||
remotePath := strings.TrimSpace(u.remotePath.Text())
|
|
||||||
u.lastLifecycleAction = "open remote vault"
|
|
||||||
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
|
||||||
binding, bindingOK := u.selectedVaultRemoteBinding()
|
|
||||||
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
|
||||||
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolved, err := binding.Resolve(preparedLocal.Model)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
|
||||||
BaseURL: resolved.Profile.BaseURL,
|
|
||||||
Username: resolved.Credentials.Username,
|
|
||||||
Password: resolved.Credentials.Password,
|
|
||||||
}, resolved.Profile.Path, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedLocalOpen(preparedLocal)
|
|
||||||
u.vaultPath.SetText(binding.LocalVaultPath)
|
|
||||||
u.noteRecentVault(binding.LocalVaultPath)
|
|
||||||
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
|
||||||
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if u.hasOpenVault() {
|
|
||||||
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if ok {
|
|
||||||
client = webdav.Client{
|
|
||||||
BaseURL: resolved.Profile.BaseURL,
|
|
||||||
Username: resolved.Credentials.Username,
|
|
||||||
Password: resolved.Credentials.Password,
|
|
||||||
}
|
|
||||||
remotePath = resolved.Profile.Path
|
|
||||||
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
|
||||||
u.remotePath.SetText(resolved.Profile.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedRemoteOpen(prepared)
|
|
||||||
if err := u.materializeCurrentRemoteCache(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.noteRecentRemote(
|
|
||||||
strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
remotePath,
|
|
||||||
)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) lockAction() error {
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err := u.state.Lock(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) unlockAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.Unlock(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startUnlockAction() {
|
|
||||||
manager, ok := u.state.Session.(*session.Manager)
|
|
||||||
if !ok {
|
|
||||||
u.runAction("unlock vault", u.unlockAction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
|
||||||
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
|
||||||
prepared, err := session.PrepareUnlock(encoded, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
manager.ApplyPreparedUnlock(prepared)
|
|
||||||
u.resetPasswordPeek()
|
|
||||||
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
||||||
u.loadSecuritySettingsFromSession()
|
|
||||||
u.editingEntry = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) changeMasterKeyAction() error {
|
|
||||||
key, err := u.currentMasterKey()
|
|
||||||
defer u.clearMasterPassword()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.state.ChangeMasterKey(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) loadSecuritySettingsFromSession() {
|
|
||||||
settings, err := u.state.SecuritySettings()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.securityCipher.SetText(settings.Cipher)
|
|
||||||
u.securityKDF.SetText(settings.KDF)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) clearMasterPassword() {
|
|
||||||
u.masterPassword.SetText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) synchronizeAction() error {
|
|
||||||
if err := u.state.Synchronize(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openAdvancedSyncDialog() {
|
|
||||||
u.syncDialogOpen = true
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.syncDialogList.Position = layout.Position{}
|
|
||||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
|
||||||
u.syncSourceMode = u.syncDefaultSourceMode
|
|
||||||
u.syncDirection = u.syncDefaultDirection
|
|
||||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
|
||||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
|
||||||
}
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) openRemoteSyncSetupDialog() {
|
|
||||||
u.syncDialogOpen = true
|
|
||||||
u.syncMenuOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.syncDialogList.Position = layout.Position{}
|
|
||||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
|
||||||
u.syncSourceMode = syncSourceRemote
|
|
||||||
u.syncDirection = syncDirectionPush
|
|
||||||
u.syncSetupAutomatic.Value = true
|
|
||||||
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
|
||||||
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
|
||||||
}
|
|
||||||
u.syncSavedRemoteBindingSelection()
|
|
||||||
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
|
||||||
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
|
||||||
u.syncSetupAutomatic.Value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) clearSyncLocalImport() {
|
|
||||||
u.syncLocalImportName = ""
|
|
||||||
u.syncLocalImportContent = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
|
||||||
name := strings.TrimSpace(u.syncLocalImportName)
|
|
||||||
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
|
||||||
switch mode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
return syncSourceRemote
|
|
||||||
default:
|
|
||||||
return syncSourceLocal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
|
||||||
switch direction {
|
|
||||||
case syncDirectionPush:
|
|
||||||
return syncDirectionPush
|
|
||||||
default:
|
|
||||||
return syncDirectionPull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncAction() error {
|
|
||||||
switch u.syncDirection {
|
|
||||||
case syncDirectionPush:
|
|
||||||
return u.advancedSyncToAction()
|
|
||||||
default:
|
|
||||||
return u.advancedSyncFromAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncFromAction() error {
|
|
||||||
switch u.syncSourceMode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
|
||||||
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.syncDialogOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startChooseSyncLocalSourceAction() {
|
|
||||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
|
||||||
u.runAction("choose sync path", func() error {
|
|
||||||
u.clearSyncLocalImport()
|
|
||||||
return u.chooseExistingFileAction(&u.syncLocalPath)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
label := "Selected Android vault"
|
|
||||||
return func() error {
|
|
||||||
u.syncLocalImportName = label
|
|
||||||
u.syncLocalImportContent = append([]byte(nil), content...)
|
|
||||||
u.syncLocalPath.SetText(label)
|
|
||||||
return nil
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
|
||||||
if named, ok := file.(interface{ Name() string }); ok {
|
|
||||||
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fallback = filepath.Base(strings.TrimSpace(fallback))
|
|
||||||
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
|
||||||
return "selected-vault.kdbx"
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startChooseVaultPathAction() {
|
|
||||||
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
|
||||||
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
name := pickedDocumentName(file, "selected-vault.kdbx")
|
|
||||||
return func() error {
|
|
||||||
return u.importSharedVaultBytesAction(name, content)
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) startImportSharedVaultAction() {
|
|
||||||
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
|
||||||
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, explorer.ErrUserDecline) {
|
|
||||||
return func() error { return nil }, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return func() error {
|
|
||||||
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) advancedSyncToAction() error {
|
|
||||||
switch u.syncSourceMode {
|
|
||||||
case syncSourceRemote:
|
|
||||||
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
|
||||||
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
|
||||||
client := webdav.Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
|
||||||
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.showStatusMessage("Remote sync is set up for this vault.")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
path := strings.TrimSpace(u.syncLocalPath.Text())
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.SynchronizeToLocal(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u.syncDialogOpen = false
|
|
||||||
u.showSyncPassword = false
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
|
||||||
baseURL = strings.TrimSpace(baseURL)
|
|
||||||
remotePath = strings.TrimSpace(remotePath)
|
|
||||||
if baseURL == "" || remotePath == "" {
|
|
||||||
return fmt.Errorf("remote setup requires base URL and path")
|
|
||||||
}
|
|
||||||
input := appstate.RemoteBindingInput{
|
|
||||||
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
|
||||||
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
|
||||||
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
|
||||||
BaseURL: baseURL,
|
|
||||||
RemotePath: remotePath,
|
|
||||||
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
|
||||||
CredentialTitle: "WebDAV Sign-In" + func() string {
|
|
||||||
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
|
||||||
return " · " + user
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}(),
|
|
||||||
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
|
||||||
Password: u.syncRemotePassword.Text(),
|
|
||||||
CredentialPath: append([]string(nil), u.currentPath...),
|
|
||||||
SyncMode: u.syncSetupMode(),
|
|
||||||
}
|
|
||||||
binding, err := u.state.ConfigureRemoteBinding(input)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
|
||||||
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
|
||||||
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
|
||||||
u.remoteBaseURL.SetText(baseURL)
|
|
||||||
u.remotePath.SetText(remotePath)
|
|
||||||
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
|
||||||
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
|
||||||
u.noteRecentRemote(baseURL, remotePath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) saveAsTargetPath() string {
|
|
||||||
path := strings.TrimSpace(u.saveAsPath.Text())
|
|
||||||
if path != "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return u.defaultSaveAsPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) importedVaultDestination(name string) string {
|
|
||||||
baseTarget := u.saveAsTargetPath()
|
|
||||||
baseDir := filepath.Dir(baseTarget)
|
|
||||||
baseName := filepath.Base(strings.TrimSpace(name))
|
|
||||||
switch {
|
|
||||||
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
|
||||||
return baseTarget
|
|
||||||
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
|
||||||
return filepath.Join(baseDir, baseName)
|
|
||||||
default:
|
|
||||||
return baseTarget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) consumePendingSharedVaultImport() {
|
|
||||||
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
|
||||||
if path == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := "shared-vault.kdbx"
|
|
||||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
|
||||||
if rawName, err := os.ReadFile(namePath); err == nil {
|
|
||||||
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
|
||||||
name = trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
|
||||||
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = os.Remove(path)
|
|
||||||
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
|
||||||
_ = os.Remove(namePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
|
||||||
target := u.importedVaultDestination(name)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.lifecycleMode = "local"
|
|
||||||
u.vaultPath.SetText(target)
|
|
||||||
u.noteRecentVault(target)
|
|
||||||
u.state.ErrorMessage = ""
|
|
||||||
u.state.StatusMessage = ""
|
|
||||||
u.requestMasterPassFocus = true
|
|
||||||
u.filter()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) currentShareableVaultPath() string {
|
|
||||||
return strings.TrimSpace(u.vaultPath.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) shareCurrentVaultAction() error {
|
|
||||||
if u.vaultSharer == nil {
|
|
||||||
return fmt.Errorf("vault sharing is not available on this platform")
|
|
||||||
}
|
|
||||||
path := u.currentShareableVaultPath()
|
|
||||||
if path == "" {
|
|
||||||
return errors.New(errVaultPathRequired)
|
|
||||||
}
|
|
||||||
if err := u.state.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
type TopSection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TopSearch TopSection = "search"
|
|
||||||
TopNavigation TopSection = "navigation"
|
|
||||||
TopPath TopSection = "path"
|
|
||||||
TopGroup TopSection = "group"
|
|
||||||
TopGroupTools TopSection = "group_tools"
|
|
||||||
TopPrimary TopSection = "primary"
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package list
|
|
||||||
|
|
||||||
type EntriesSectionState struct {
|
|
||||||
Path []string
|
|
||||||
SearchQuery string
|
|
||||||
SelectedEntryID string
|
|
||||||
Editing bool
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//go:build android
|
|
||||||
|
|
||||||
package platform
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo CFLAGS: -Werror
|
|
||||||
#cgo LDFLAGS: -landroid
|
|
||||||
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
import "unsafe"
|
|
||||||
|
|
||||||
func LogInfo(tag, msg string) {
|
|
||||||
ctag := C.CString(tag)
|
|
||||||
defer C.free(unsafe.Pointer(ctag))
|
|
||||||
cmsg := C.CString(msg)
|
|
||||||
defer C.free(unsafe.Pointer(cmsg))
|
|
||||||
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//go:build !android
|
|
||||||
|
|
||||||
package platform
|
|
||||||
|
|
||||||
import "log"
|
|
||||||
|
|
||||||
func NewVaultSharer(goos string) VaultSharer {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogInfo(tag, msg string) {
|
|
||||||
log.Printf("%s: %s", tag, msg)
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/app"
|
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/x/explorer"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/api"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Main() {
|
|
||||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
|
||||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
|
||||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
|
||||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
|
||||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
|
||||||
|
|
||||||
width := unit.Dp(1180)
|
|
||||||
height := unit.Dp(760)
|
|
||||||
if strings.EqualFold(resolvedMode, "phone") {
|
|
||||||
width = unit.Dp(412)
|
|
||||||
height = unit.Dp(915)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
w := new(app.Window)
|
|
||||||
options := []app.Option{app.Title(productName)}
|
|
||||||
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
|
||||||
options = append(options, app.Size(width, height))
|
|
||||||
}
|
|
||||||
w.Option(options...)
|
|
||||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
app.Main()
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultGRPCAddr(goos string) string {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
|
||||||
return "off"
|
|
||||||
}
|
|
||||||
return "127.0.0.1:47777"
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|
||||||
var ops op.Ops
|
|
||||||
manager := &session.Manager{}
|
|
||||||
ui := newUIWithSession(mode, manager, paths)
|
|
||||||
ui.fileExplorer = explorer.NewExplorer(w)
|
|
||||||
ui.invalidate = w.Invalidate
|
|
||||||
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
|
||||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
|
||||||
if err != nil {
|
|
||||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
|
||||||
} else if host != nil {
|
|
||||||
ui.apiHost = host
|
|
||||||
ui.auditLog = host.Server().AuditLog()
|
|
||||||
ui.grpcAddress = host.Address()
|
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
|
||||||
defer func() { _ = host.Stop() }()
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
e := w.Event()
|
|
||||||
ui.fileExplorer.ListenEvents(e)
|
|
||||||
switch e := e.(type) {
|
|
||||||
case app.DestroyEvent:
|
|
||||||
return e.Err
|
|
||||||
case app.FrameEvent:
|
|
||||||
gtx := app.NewContext(&ops, e)
|
|
||||||
ui.processBackgroundActions()
|
|
||||||
ui.layout(gtx)
|
|
||||||
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
|
||||||
e.Frame(gtx.Ops)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type uiApprovalManager struct {
|
|
||||||
server *api.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
|
||||||
if m == nil || m.server == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m.server.ApprovalBroker().Pending()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
|
||||||
if m == nil || m.server == nil {
|
|
||||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
|
||||||
}
|
|
||||||
return m.server.ResolveApproval(id, outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
type uiSession struct {
|
|
||||||
model vault.Model
|
|
||||||
locked bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) HasVault() bool {
|
|
||||||
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) IsLocked() bool {
|
|
||||||
return s.locked
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) IsRemote() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Current() (vault.Model, error) {
|
|
||||||
if s.locked {
|
|
||||||
return vault.Model{}, session.ErrLocked
|
|
||||||
}
|
|
||||||
return s.model, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Replace(model vault.Model) {
|
|
||||||
s.model = model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Lock() error {
|
|
||||||
s.locked = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *uiSession) Unlock(vault.MasterKey) error {
|
|
||||||
if !s.locked {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.locked = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickExistingFile() (string, error) {
|
|
||||||
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runFilePicker(name string, args ...string) (string, error) {
|
|
||||||
if _, err := exec.LookPath(name); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return parsePickedFilePath(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePickedFilePath(output []byte) (string, error) {
|
|
||||||
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
|
||||||
for i := len(lines) - 1; i >= 0; i-- {
|
|
||||||
line := strings.TrimSpace(lines[i])
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
|
||||||
return line, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("file picker did not return a path")
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package settings
|
|
||||||
|
|
||||||
type AccessibilityPreferences struct {
|
|
||||||
DisplayDensity string
|
|
||||||
Contrast string
|
|
||||||
ReducedMotion bool
|
|
||||||
KeyboardFocus string
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
DisplayDensityDense = "dense"
|
|
||||||
DisplayDensityComfortable = "comfortable"
|
|
||||||
ContrastStandard = "standard"
|
|
||||||
ContrastHigh = "high"
|
|
||||||
KeyboardFocusStandard = "standard"
|
|
||||||
KeyboardFocusProminent = "prominent"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
|
||||||
return AccessibilityPreferences{
|
|
||||||
DisplayDensity: DisplayDensityDense,
|
|
||||||
Contrast: ContrastStandard,
|
|
||||||
KeyboardFocus: KeyboardFocusStandard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DisplayDensityForDenseLayout(dense bool) string {
|
|
||||||
if dense {
|
|
||||||
return DisplayDensityDense
|
|
||||||
}
|
|
||||||
return DisplayDensityComfortable
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
|
||||||
normalized := DefaultAccessibilityPreferences()
|
|
||||||
switch prefs.DisplayDensity {
|
|
||||||
case DisplayDensityDense, DisplayDensityComfortable:
|
|
||||||
normalized.DisplayDensity = prefs.DisplayDensity
|
|
||||||
}
|
|
||||||
switch prefs.Contrast {
|
|
||||||
case ContrastStandard, ContrastHigh:
|
|
||||||
normalized.Contrast = prefs.Contrast
|
|
||||||
}
|
|
||||||
switch prefs.KeyboardFocus {
|
|
||||||
case KeyboardFocusStandard, KeyboardFocusProminent:
|
|
||||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
|
||||||
}
|
|
||||||
normalized.ReducedMotion = prefs.ReducedMotion
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
|
|
||||||
type SourceMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SourceLocal SourceMode = "local"
|
|
||||||
SourceRemote SourceMode = "remote"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Direction string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DirectionPull Direction = "pull"
|
|
||||||
DirectionPush Direction = "push"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DialogPurpose string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DialogPurposeAdvanced DialogPurpose = "advanced"
|
|
||||||
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MenuModel struct {
|
|
||||||
HasOpenVault bool
|
|
||||||
HasSelectedBinding bool
|
|
||||||
ShowSelectors bool
|
|
||||||
ShowShare bool
|
|
||||||
ShowSaveCurrentBinding bool
|
|
||||||
SavedBindingSummary MenuBindingSummary
|
|
||||||
RemoteBaseURL string
|
|
||||||
RemotePath string
|
|
||||||
RemoteUsername string
|
|
||||||
RemotePassword string
|
|
||||||
SelectedVaultSyncMode appstate.SyncMode
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuBindingSummary struct {
|
|
||||||
ProfileLabel string
|
|
||||||
CredentialLabel string
|
|
||||||
SyncLabel string
|
|
||||||
OK bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SavedBindingHeading() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use this vault's saved remote sync target"
|
|
||||||
}
|
|
||||||
return "Use a saved remote profile from this vault"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) OpenSelectedButtonLabel() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use Remote Sync"
|
|
||||||
}
|
|
||||||
return "Open Saved Remote"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
|
||||||
|
|
||||||
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
|
||||||
return m.HasOpenVault && !m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
|
||||||
|
|
||||||
func (m MenuModel) ActionLabels() []string {
|
|
||||||
labels := []string{"Open Advanced Sync"}
|
|
||||||
if m.ShowRemoteSyncSetupShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowDirectRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
|
||||||
return "Bind this local vault to the current remote target"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
|
||||||
|
|
||||||
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
|
||||||
if purpose == DialogPurposeRemoteSetup {
|
|
||||||
return "Push this local vault to a WebDAV target and save that target for future sync."
|
|
||||||
}
|
|
||||||
sourceLabel := "another local vault file"
|
|
||||||
if source == SourceRemote {
|
|
||||||
sourceLabel = "another WebDAV-backed vault"
|
|
||||||
}
|
|
||||||
action := "Pull changes from"
|
|
||||||
if direction == DirectionPush {
|
|
||||||
action = "Push the current vault into"
|
|
||||||
}
|
|
||||||
return action + " " + sourceLabel + "."
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op/clip"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Stack{}.Layout(gtx,
|
|
||||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
||||||
}),
|
|
||||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
width := gtx.Dp(unit.Dp(620))
|
|
||||||
if width > gtx.Constraints.Max.X {
|
|
||||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
|
||||||
}
|
|
||||||
if width < 1 {
|
|
||||||
width = gtx.Constraints.Max.X
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
return card(gtx, u.syncDialogContent)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
|
||||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
|
||||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
|
||||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
|
||||||
}
|
|
||||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncDirectionChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncDirectionChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncSourceChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.shouldShowSyncSourceChoices() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.syncSourceMode == syncSourceRemote {
|
|
||||||
children := []layout.FlexChild{
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.syncPasswordField(gtx)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
|
||||||
check.Color = accentColor
|
|
||||||
return check.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if len(matchingCredentials) > 0 {
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for i, entry := range matchingCredentials {
|
|
||||||
i := i
|
|
||||||
entry := entry
|
|
||||||
children = append(children,
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
label := entry.Title
|
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
|
||||||
label += " · " + strings.TrimSpace(entry.Username)
|
|
||||||
}
|
|
||||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
||||||
}
|
|
||||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
|
||||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
field := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
|
||||||
editor.Color = u.theme.Palette.Fg
|
|
||||||
editor.HintColor = mutedColor
|
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
|
||||||
}
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.outlinedFieldState(gtx, false, field)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = keepassgo-git
|
pkgbase = keepassgo-git
|
||||||
pkgdesc = KeePass-compatible password manager written in Go
|
pkgdesc = KeePass-compatible password manager written in Go
|
||||||
pkgver = r165.1c72a50
|
pkgver = r160.5fa79bd
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://git.julianfamily.org/joejulian/keepassgo
|
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
|
|||||||
@@ -41,16 +41,16 @@ build() {
|
|||||||
export GOFLAGS="-trimpath"
|
export GOFLAGS="-trimpath"
|
||||||
local app_version
|
local app_version
|
||||||
app_version="$(git describe --tags --always --dirty)"
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo .
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
install -Dm644 assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
install -Dm644 assets/keepassgo-icon.svg \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
|
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
|
||||||
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
|
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
|
||||||
"${pkgdir}/usr/share/applications/keepassgo.desktop"
|
"${pkgdir}/usr/share/applications/keepassgo.desktop"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
"github.com/tobischo/gokeepasslib/v3"
|
"github.com/tobischo/gokeepasslib/v3"
|
||||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: debian/testing
|
||||||
|
packages:
|
||||||
|
- clang
|
||||||
|
- cmake
|
||||||
|
- curl
|
||||||
|
- autoconf
|
||||||
|
- libxml2-dev
|
||||||
|
- libssl-dev
|
||||||
|
- libz-dev
|
||||||
|
- llvm-dev # for cctools
|
||||||
|
- uuid-dev ## for cctools
|
||||||
|
- libplist-utils # for gogio
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
- https://git.sr.ht/~eliasnaur/applesdks
|
||||||
|
- https://git.sr.ht/~eliasnaur/giouiorg
|
||||||
|
- https://github.com/tpoechtrager/cctools-port.git
|
||||||
|
- https://github.com/tpoechtrager/apple-libtapi.git
|
||||||
|
- https://github.com/mackyle/xar.git
|
||||||
|
environment:
|
||||||
|
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
|
||||||
|
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- prepare_toolchain: |
|
||||||
|
mkdir -p $APPLE_TOOLCHAIN_ROOT
|
||||||
|
cd $APPLE_TOOLCHAIN_ROOT
|
||||||
|
tar xJf /home/build/applesdks/applesdks.tar.xz
|
||||||
|
mkdir bin tools
|
||||||
|
cd bin
|
||||||
|
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
|
||||||
|
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
|
||||||
|
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
|
||||||
|
ln -s ../tools/appletoolchain xcrun
|
||||||
|
ln -s /usr/bin/plistutil plutil
|
||||||
|
cd ../tools
|
||||||
|
ln -s appletoolchain clang-ios
|
||||||
|
ln -s appletoolchain clang-macos
|
||||||
|
- install_appletoolchain: |
|
||||||
|
cd giouiorg
|
||||||
|
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
|
||||||
|
- build_xar: |
|
||||||
|
cd xar/xar
|
||||||
|
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
|
||||||
|
make
|
||||||
|
sudo make install
|
||||||
|
- build_libtapi: |
|
||||||
|
cd apple-libtapi
|
||||||
|
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
|
||||||
|
./install.sh
|
||||||
|
- build_cctools: |
|
||||||
|
cd cctools-port/cctools
|
||||||
|
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
|
||||||
|
make install
|
||||||
|
- install_gogio: |
|
||||||
|
cd gio-cmd
|
||||||
|
go install ./gogio
|
||||||
|
- test_ios_gogio: |
|
||||||
|
mkdir tmp
|
||||||
|
cd tmp
|
||||||
|
go mod init example.com
|
||||||
|
go get -d gioui.org/example/kitchen
|
||||||
|
export PATH=/home/build/appletools/bin:$PATH
|
||||||
|
gogio -target ios -o app.app gioui.org/example/kitchen
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: freebsd/13.x
|
||||||
|
packages:
|
||||||
|
- libX11
|
||||||
|
- libxkbcommon
|
||||||
|
- libXcursor
|
||||||
|
- libXfixes
|
||||||
|
- vulkan-headers
|
||||||
|
- wayland
|
||||||
|
- mesa-libs
|
||||||
|
- xorg-vfbserver
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- test_cmd: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: debian/bookworm
|
||||||
|
packages:
|
||||||
|
- curl
|
||||||
|
- pkg-config
|
||||||
|
- libwayland-dev
|
||||||
|
- libx11-dev
|
||||||
|
- libx11-xcb-dev
|
||||||
|
- libxkbcommon-dev
|
||||||
|
- libxkbcommon-x11-dev
|
||||||
|
- libgles2-mesa-dev
|
||||||
|
- libegl1-mesa-dev
|
||||||
|
- libffi-dev
|
||||||
|
- libvulkan-dev
|
||||||
|
- libxcursor-dev
|
||||||
|
- libxrandr-dev
|
||||||
|
- libxinerama-dev
|
||||||
|
- libxi-dev
|
||||||
|
- libxxf86vm-dev
|
||||||
|
- mesa-vulkan-drivers
|
||||||
|
- wine
|
||||||
|
- xvfb
|
||||||
|
- xdotool
|
||||||
|
- scrot
|
||||||
|
- sway
|
||||||
|
- grim
|
||||||
|
- wine
|
||||||
|
- unzip
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
|
||||||
|
ANDROID_SDK_ROOT: /home/build/android
|
||||||
|
android_sdk_tools_zip: sdk-tools-linux-3859397.zip
|
||||||
|
android_ndk_zip: android-ndk-r20-linux-x86_64.zip
|
||||||
|
github_mirror: git@github.com:gioui/gio-cmd
|
||||||
|
secrets:
|
||||||
|
- fdc570bf-87f4-4528-8aee-4d1711b1c86f
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
- check_gofmt: |
|
||||||
|
cd gio-cmd
|
||||||
|
test -z "$(gofmt -s -l .)"
|
||||||
|
- check_sign_off: |
|
||||||
|
set +x -e
|
||||||
|
cd gio-cmd
|
||||||
|
for hash in $(git log -n 20 --format="%H"); do
|
||||||
|
message=$(git log -1 --format=%B $hash)
|
||||||
|
if [[ ! "$message" =~ "Signed-off-by: " ]]; then
|
||||||
|
echo "Missing 'Signed-off-by' in commit $hash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
- mirror: |
|
||||||
|
# mirror to github
|
||||||
|
ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
|
||||||
|
- install_chrome: |
|
||||||
|
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||||
|
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||||
|
sudo apt-get -qq update
|
||||||
|
sudo apt-get -qq install -y google-chrome-stable
|
||||||
|
- test: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
- install_jdk8: |
|
||||||
|
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
|
||||||
|
sudo apt-get -qq install -y -f ./jdk.deb
|
||||||
|
- install_android: |
|
||||||
|
mkdir android
|
||||||
|
cd android
|
||||||
|
curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
|
||||||
|
unzip -q sdk-tools.zip
|
||||||
|
rm sdk-tools.zip
|
||||||
|
curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
|
||||||
|
unzip -q ndk.zip
|
||||||
|
rm ndk.zip
|
||||||
|
mv android-ndk-* ndk-bundle
|
||||||
|
yes|sdkmanager --licenses
|
||||||
|
sdkmanager "platforms;android-31" "build-tools;32.0.0"
|
||||||
|
- install_gogio: |
|
||||||
|
cd gio-cmd
|
||||||
|
go install ./gogio
|
||||||
|
- test_android_gogio: |
|
||||||
|
mkdir tmp
|
||||||
|
cd tmp
|
||||||
|
go mod init example.com
|
||||||
|
go get -d gioui.org/example/kitchen
|
||||||
|
gogio -target android gioui.org/example/kitchen
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
image: openbsd/latest
|
||||||
|
packages:
|
||||||
|
- libxkbcommon
|
||||||
|
- go
|
||||||
|
sources:
|
||||||
|
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||||
|
environment:
|
||||||
|
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||||
|
tasks:
|
||||||
|
- install_go: |
|
||||||
|
mkdir -p /home/build/sdk
|
||||||
|
curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
|
||||||
|
cd /home/build/sdk/go/src
|
||||||
|
./make.bash
|
||||||
|
- test_cmd: |
|
||||||
|
cd gio-cmd
|
||||||
|
go test ./...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
This project is provided under the terms of the UNLICENSE or
|
||||||
|
the MIT license denoted by the following SPDX identifier:
|
||||||
|
|
||||||
|
SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
You may use the project under the terms of either license.
|
||||||
|
|
||||||
|
Both licenses are reproduced below.
|
||||||
|
|
||||||
|
----
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019 The Gio authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
The UNLICENSE
|
||||||
|
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
||||||
|
---
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Gio Tools
|
||||||
|
|
||||||
|
Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
|
||||||
|
|
||||||
|
[](https://builds.sr.ht/~eliasnaur/gio-cmd)
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
|
||||||
|
to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
|
||||||
|
mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
|
||||||
|
[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
|
||||||
|
account is required and you can post without being subscribed.
|
||||||
|
|
||||||
|
See the [contribution guide](https://gioui.org/doc/contribute) for more details.
|
||||||
|
|
||||||
|
An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module gioui.org/cmd
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.8.0
|
||||||
|
github.com/akavel/rsrc v0.10.1
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
|
||||||
|
github.com/chromedp/chromedp v0.5.2
|
||||||
|
golang.org/x/image v0.18.0
|
||||||
|
golang.org/x/sync v0.7.0
|
||||||
|
golang.org/x/text v0.16.0
|
||||||
|
golang.org/x/tools v0.23.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.1 // indirect
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
|
||||||
|
github.com/gobwas/pool v0.2.0 // indirect
|
||||||
|
github.com/gobwas/ws v1.0.2 // indirect
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/mod v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||||
|
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||||
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
|
||||||
|
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
||||||
|
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
||||||
|
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
||||||
|
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||||
|
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||||
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
|
||||||
|
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
|
||||||
|
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
|
||||||
|
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||||
|
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
|
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AndroidTestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
sdkDir string
|
||||||
|
adbPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Start(path string) {
|
||||||
|
d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
|
||||||
|
if d.sdkDir == "" {
|
||||||
|
d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
|
||||||
|
}
|
||||||
|
d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
|
||||||
|
if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
|
||||||
|
d.Skipf("adb not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
devOut := bytes.TrimSpace(d.adb("devices"))
|
||||||
|
devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
|
||||||
|
switch len(devices) {
|
||||||
|
case 0:
|
||||||
|
d.Skipf("no Android devices attached via adb; skipping")
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
d.Skipf("multiple Android devices attached via adb; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the device is attached but asleep, it's probably just charging.
|
||||||
|
// Don't use it; the screen needs to be on and unlocked for the test to
|
||||||
|
// work.
|
||||||
|
if !bytes.Contains(
|
||||||
|
d.adb("shell", "dumpsys", "power"),
|
||||||
|
[]byte(" mWakefulness=Awake"),
|
||||||
|
) {
|
||||||
|
d.Skipf("Android device isn't awake; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
|
||||||
|
d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)
|
||||||
|
|
||||||
|
// Make sure the app isn't installed already, and try to uninstall it
|
||||||
|
// when we finish. Previous failed test runs might have left the app.
|
||||||
|
d.tryUninstall()
|
||||||
|
d.adb("install", apk)
|
||||||
|
d.Cleanup(d.tryUninstall)
|
||||||
|
|
||||||
|
// Force our e2e app to be fullscreen, so that the android system bar at
|
||||||
|
// the top doesn't mess with our screenshots.
|
||||||
|
// TODO(mvdan): is there a way to do this via gio, so that we don't need
|
||||||
|
// to set up a global Android setting via the shell?
|
||||||
|
d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)
|
||||||
|
|
||||||
|
// Make sure the app isn't already running.
|
||||||
|
d.adb("shell", "pm", "clear", appid)
|
||||||
|
|
||||||
|
// Start listening for log messages.
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd := exec.CommandContext(ctx, d.adbPath,
|
||||||
|
"logcat",
|
||||||
|
"-s", // suppress other logs
|
||||||
|
"-T1", // don't show previous log messages
|
||||||
|
appid+":*", // show all logs from our gio app ID
|
||||||
|
)
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
d.output = output
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the app.
|
||||||
|
d.adb("shell", "monkey", "-p", appid, "1")
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Screenshot() image.Image {
|
||||||
|
out := d.adb("shell", "screencap", "-p")
|
||||||
|
img, err := png.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) tryUninstall() {
|
||||||
|
cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if bytes.Contains(out, []byte("Unknown package")) {
|
||||||
|
// The package is not installed. Don't log anything.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Logf("could not uninstall: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
|
||||||
|
strs := []string{}
|
||||||
|
for _, arg := range args {
|
||||||
|
strs = append(strs, fmt.Sprint(arg))
|
||||||
|
}
|
||||||
|
cmd := exec.Command(d.adbPath, strs...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
d.Errorf("%s", out)
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AndroidTestDriver) Click(x, y int) {
|
||||||
|
d.adb("shell", "input", "tap", x, y)
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type buildInfo struct {
|
||||||
|
appID string
|
||||||
|
archs []string
|
||||||
|
ldflags string
|
||||||
|
minsdk int
|
||||||
|
targetsdk int
|
||||||
|
name string
|
||||||
|
pkgDir string
|
||||||
|
pkgPath string
|
||||||
|
iconPath string
|
||||||
|
tags string
|
||||||
|
target string
|
||||||
|
version Semver
|
||||||
|
key string
|
||||||
|
password string
|
||||||
|
notaryAppleID string
|
||||||
|
notaryPassword string
|
||||||
|
notaryTeamID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Semver struct {
|
||||||
|
Major, Minor, Patch int
|
||||||
|
VersionCode uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuildInfo(pkgPath string) (*buildInfo, error) {
|
||||||
|
pkgMetadata, err := getPkgMetadata(pkgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
appID := getAppID(pkgMetadata)
|
||||||
|
appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
|
||||||
|
if *iconPath != "" {
|
||||||
|
appIcon = *iconPath
|
||||||
|
}
|
||||||
|
appName := getPkgName(pkgMetadata)
|
||||||
|
if *name != "" {
|
||||||
|
appName = *name
|
||||||
|
}
|
||||||
|
ver, err := parseSemver(*version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bi := &buildInfo{
|
||||||
|
appID: appID,
|
||||||
|
archs: getArchs(),
|
||||||
|
ldflags: getLdFlags(appID),
|
||||||
|
minsdk: *minsdk,
|
||||||
|
targetsdk: *targetsdk,
|
||||||
|
name: appName,
|
||||||
|
pkgDir: pkgMetadata.Dir,
|
||||||
|
pkgPath: pkgPath,
|
||||||
|
iconPath: appIcon,
|
||||||
|
tags: *extraTags,
|
||||||
|
target: *target,
|
||||||
|
version: ver,
|
||||||
|
key: *signKey,
|
||||||
|
password: *signPass,
|
||||||
|
notaryAppleID: *notaryID,
|
||||||
|
notaryPassword: *notaryPass,
|
||||||
|
notaryTeamID: *notaryTeamID,
|
||||||
|
}
|
||||||
|
return bi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UppercaseName returns a string with its first rune in uppercase.
|
||||||
|
func UppercaseName(name string) string {
|
||||||
|
ch, w := utf8.DecodeRuneInString(name)
|
||||||
|
return string(unicode.ToUpper(ch)) + name[w:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Semver) String() string {
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSemver(v string) (Semver, error) {
|
||||||
|
var sv Semver
|
||||||
|
_, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode)
|
||||||
|
if err != nil {
|
||||||
|
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||||
|
}
|
||||||
|
if sv.String() != v {
|
||||||
|
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||||
|
}
|
||||||
|
return sv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArchs() []string {
|
||||||
|
if *archNames != "" {
|
||||||
|
return strings.Split(*archNames, ",")
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "js":
|
||||||
|
return []string{"wasm"}
|
||||||
|
case "ios", "tvos":
|
||||||
|
// Only 64-bit support.
|
||||||
|
return []string{"arm64", "amd64"}
|
||||||
|
case "android":
|
||||||
|
return []string{"arm", "arm64", "386", "amd64"}
|
||||||
|
case "windows":
|
||||||
|
goarch := os.Getenv("GOARCH")
|
||||||
|
if goarch == "" {
|
||||||
|
goarch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
return []string{goarch}
|
||||||
|
case "macos":
|
||||||
|
return []string{"arm64", "amd64"}
|
||||||
|
default:
|
||||||
|
// TODO: Add flag tests.
|
||||||
|
panic("The target value has already been validated, this will never execute.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLdFlags(appID string) string {
|
||||||
|
var ldflags []string
|
||||||
|
if extra := *extraLdflags; extra != "" {
|
||||||
|
ldflags = append(ldflags, strings.Split(extra, " ")...)
|
||||||
|
}
|
||||||
|
// Pass appID along, to be used for logging on platforms like Android.
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID))
|
||||||
|
// Support earlier Gio versions that had a separate app id recorded.
|
||||||
|
// TODO: delete this in the future.
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
|
||||||
|
// Pass along all remaining arguments to the app.
|
||||||
|
if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
|
||||||
|
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
|
||||||
|
}
|
||||||
|
if m := *linkMode; m != "" {
|
||||||
|
ldflags = append(ldflags, "-linkmode="+m)
|
||||||
|
}
|
||||||
|
return strings.Join(ldflags, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageMetadata struct {
|
||||||
|
PkgPath string
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
|
||||||
|
pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &packageMetadata{
|
||||||
|
PkgPath: pkgImportPath,
|
||||||
|
Dir: pkgDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppID(pkgMetadata *packageMetadata) string {
|
||||||
|
if *appID != "" {
|
||||||
|
return *appID
|
||||||
|
}
|
||||||
|
elems := strings.Split(pkgMetadata.PkgPath, "/")
|
||||||
|
domain := strings.Split(elems[0], ".")
|
||||||
|
name := ""
|
||||||
|
if len(elems) > 1 {
|
||||||
|
name = "." + elems[len(elems)-1]
|
||||||
|
}
|
||||||
|
if len(elems) < 2 && len(domain) < 2 {
|
||||||
|
name = "." + domain[0]
|
||||||
|
domain[0] = "localhost"
|
||||||
|
} else {
|
||||||
|
for i := 0; i < len(domain)/2; i++ {
|
||||||
|
opp := len(domain) - 1 - i
|
||||||
|
domain[i], domain[opp] = domain[opp], domain[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgDomain := strings.Join(domain, ".")
|
||||||
|
appid := []rune(pkgDomain + name)
|
||||||
|
|
||||||
|
// a Java-language-style package name may contain upper- and lower-case
|
||||||
|
// letters and underscores with individual parts separated by '.'.
|
||||||
|
// https://developer.android.com/guide/topics/manifest/manifest-element
|
||||||
|
for i, c := range appid {
|
||||||
|
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
|
||||||
|
c == '_' || c == '.') {
|
||||||
|
appid[i] = '_'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(appid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPkgName(pkgMetadata *packageMetadata) string {
|
||||||
|
return path.Base(pkgMetadata.PkgPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
type expval struct {
|
||||||
|
in, out string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []expval{
|
||||||
|
{"example", "localhost.example"},
|
||||||
|
{"example.com", "com.example"},
|
||||||
|
{"www.example.com", "com.example.www"},
|
||||||
|
{"examplecom/app", "examplecom.app"},
|
||||||
|
{"example.com/app", "com.example.app"},
|
||||||
|
{"www.example.com/app", "com.example.www.app"},
|
||||||
|
{"www.en.example.com/app", "com.example.en.www.app"},
|
||||||
|
{"example.com/dir/app", "com.example.app"},
|
||||||
|
{"example.com/dir.ext/app", "com.example.app"},
|
||||||
|
{"example.com/dir/app.ext", "com.example.app.ext"},
|
||||||
|
{"example-com.net/dir/app", "net.example_com.app"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
got := getAppID(&packageMetadata{PkgPath: test.in})
|
||||||
|
if exp := test.out; got != exp {
|
||||||
|
t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
/*
|
||||||
|
The gogio tool builds and packages Gio programs for Android, iOS/tvOS
|
||||||
|
and WebAssembly.
|
||||||
|
|
||||||
|
Run gogio with no arguments for instructions, or see the examples at
|
||||||
|
https://gioui.org.
|
||||||
|
*/
|
||||||
|
package main
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var raceEnabled = false
|
||||||
|
|
||||||
|
var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")
|
||||||
|
|
||||||
|
const appid = "localhost.gogio.endtoend"
|
||||||
|
|
||||||
|
// TestDriver is implemented by each of the platforms we can run end-to-end
|
||||||
|
// tests on. None of its methods return any errors, as the errors are directly
|
||||||
|
// reported to testing.T via methods like Fatal.
|
||||||
|
type TestDriver interface {
|
||||||
|
initBase(t *testing.T, width, height int)
|
||||||
|
|
||||||
|
// Start opens the Gio app found at path. The driver should attempt to
|
||||||
|
// run the app with the base driver's width and height, and the
|
||||||
|
// platform's background should be white.
|
||||||
|
//
|
||||||
|
// When the function returns, the gio app must be ready to use on the
|
||||||
|
// platform, with its initial frame fully drawn.
|
||||||
|
Start(path string)
|
||||||
|
|
||||||
|
// Screenshot takes a screenshot of the Gio app on the platform.
|
||||||
|
Screenshot() image.Image
|
||||||
|
|
||||||
|
// Click performs a pointer click at the specified coordinates,
|
||||||
|
// including both press and release. It returns when the next frame is
|
||||||
|
// fully drawn.
|
||||||
|
Click(x, y int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type driverBase struct {
|
||||||
|
*testing.T
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
|
||||||
|
output io.Reader
|
||||||
|
frameNotifs chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) initBase(t *testing.T, width, height int) {
|
||||||
|
d.T = t
|
||||||
|
d.width, d.height = width, height
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skipf("end-to-end tests tend to be slow")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal"
|
||||||
|
testdataWithRelativePkgPath = "internal/normal/testdata.go"
|
||||||
|
customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go"
|
||||||
|
)
|
||||||
|
// Keep this list local, to not reuse TestDriver objects.
|
||||||
|
subtests := []struct {
|
||||||
|
name string
|
||||||
|
driver TestDriver
|
||||||
|
pkgPath string
|
||||||
|
skipGeese string
|
||||||
|
}{
|
||||||
|
{"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""},
|
||||||
|
{"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"},
|
||||||
|
// Doesn't work on the builders.
|
||||||
|
//{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
|
||||||
|
{"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
{"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subtest := range subtests {
|
||||||
|
t.Run(subtest.name, func(t *testing.T) {
|
||||||
|
subtest := subtest // copy the changing loop variable
|
||||||
|
if strings.Contains(subtest.skipGeese, runtime.GOOS) {
|
||||||
|
t.Skipf("not supported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
t.Parallel()
|
||||||
|
runEndToEndTest(t, subtest.driver, subtest.pkgPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
|
||||||
|
size := image.Point{X: 800, Y: 600}
|
||||||
|
driver.initBase(t, size.X, size.Y)
|
||||||
|
|
||||||
|
t.Log("starting driver and gio app")
|
||||||
|
driver.Start(pkgPath)
|
||||||
|
|
||||||
|
beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
|
||||||
|
white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
|
||||||
|
black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
|
||||||
|
red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
// These are the four colors at the beginning.
|
||||||
|
t.Log("taking initial screenshot")
|
||||||
|
withRetries(t, 4*time.Second, func() error {
|
||||||
|
img := driver.Screenshot()
|
||||||
|
size = img.Bounds().Size() // override the default size
|
||||||
|
return checkImageCorners(img, beef, white, black, gray)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
|
||||||
|
// almost works to automate clicks, but the button presses end up in the
|
||||||
|
// wrong coordinates.
|
||||||
|
if _, ok := driver.(*WaylandTestDriver); ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the first and last sections to turn them red.
|
||||||
|
t.Log("clicking twice and taking another screenshot")
|
||||||
|
driver.Click(1*(size.X/4), 1*(size.Y/4))
|
||||||
|
driver.Click(3*(size.X/4), 3*(size.Y/4))
|
||||||
|
withRetries(t, 4*time.Second, func() error {
|
||||||
|
img := driver.Screenshot()
|
||||||
|
return checkImageCorners(img, red, white, black, red)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
|
||||||
|
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
|
||||||
|
// such, timeout should generally be in the order of seconds.
|
||||||
|
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
timeoutTimer := time.NewTimer(timeout)
|
||||||
|
defer timeoutTimer.Stop()
|
||||||
|
backoff := 100 * time.Millisecond
|
||||||
|
|
||||||
|
tries := 0
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
if lastErr = fn(); lastErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tries++
|
||||||
|
t.Logf("retrying after %s", backoff)
|
||||||
|
|
||||||
|
// Use a timer instead of a sleep, so that the timeout can stop
|
||||||
|
// the backoff early. Don't reuse this timer, since we're not in
|
||||||
|
// a hot loop, and we don't want tricky code.
|
||||||
|
backoffTimer := time.NewTimer(backoff)
|
||||||
|
defer backoffTimer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timeoutTimer.C:
|
||||||
|
t.Errorf("last error: %v", lastErr)
|
||||||
|
t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
|
||||||
|
case <-backoffTimer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep doubling it until a maximum. With the start at 100ms,
|
||||||
|
// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
|
||||||
|
backoff *= 2
|
||||||
|
if max := 2 * time.Second; backoff > max {
|
||||||
|
backoff = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type colorMismatch struct {
|
||||||
|
x, y int
|
||||||
|
wantRGB, gotRGB [3]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m colorMismatch) String() string {
|
||||||
|
return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
|
||||||
|
m.x, m.y,
|
||||||
|
m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
|
||||||
|
m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
|
||||||
|
// The colors are split in four rectangular sections. Check the corners
|
||||||
|
// of each of the sections. We check the corners left to right, top to
|
||||||
|
// bottom, like when reading left-to-right text.
|
||||||
|
|
||||||
|
size := img.Bounds().Size()
|
||||||
|
var mismatches []colorMismatch
|
||||||
|
|
||||||
|
checkColor := func(x, y int, want color.Color) {
|
||||||
|
r, g, b, _ := want.RGBA()
|
||||||
|
got := img.At(x, y)
|
||||||
|
r_, g_, b_, _ := got.RGBA()
|
||||||
|
if r_ != r || g_ != g || b_ != b {
|
||||||
|
mismatches = append(mismatches, colorMismatch{
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
wantRGB: [3]uint32{r, g, b},
|
||||||
|
gotRGB: [3]uint32{r_, g_, b_},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
minX, minY := 5, 5
|
||||||
|
maxX, maxY := (size.X/2)-5, (size.Y/2)-5
|
||||||
|
checkColor(minX, minY, topLeft)
|
||||||
|
checkColor(maxX, minY, topLeft)
|
||||||
|
checkColor(minX, maxY, topLeft)
|
||||||
|
checkColor(maxX, maxY, topLeft)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := (size.X/2)+5, 5
|
||||||
|
maxX, maxY := size.X-5, (size.Y/2)-5
|
||||||
|
checkColor(minX, minY, topRight)
|
||||||
|
checkColor(maxX, minY, topRight)
|
||||||
|
checkColor(minX, maxY, topRight)
|
||||||
|
checkColor(maxX, maxY, topRight)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := 5, (size.Y/2)+5
|
||||||
|
maxX, maxY := (size.X/2)-5, size.Y-5
|
||||||
|
checkColor(minX, minY, botLeft)
|
||||||
|
checkColor(maxX, minY, botLeft)
|
||||||
|
checkColor(minX, maxY, botLeft)
|
||||||
|
checkColor(maxX, maxY, botLeft)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
minX, minY := (size.X/2)+5, (size.Y/2)+5
|
||||||
|
maxX, maxY := size.X-5, size.Y-5
|
||||||
|
checkColor(minX, minY, botRight)
|
||||||
|
checkColor(maxX, minY, botRight)
|
||||||
|
checkColor(minX, maxY, botRight)
|
||||||
|
checkColor(maxX, maxY, botRight)
|
||||||
|
}
|
||||||
|
if n := len(mismatches); n > 0 {
|
||||||
|
b := new(strings.Builder)
|
||||||
|
fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
|
||||||
|
for _, m := range mismatches {
|
||||||
|
fmt.Fprintf(b, "%s\n", m)
|
||||||
|
}
|
||||||
|
return errors.New(b.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) waitForFrame() {
|
||||||
|
d.Helper()
|
||||||
|
|
||||||
|
if d.frameNotifs == nil {
|
||||||
|
// Start the goroutine that reads output lines and notifies of
|
||||||
|
// new frames via frameNotifs. The test doesn't wait for this
|
||||||
|
// goroutine to finish; it will naturally end when the output
|
||||||
|
// reader reaches an error like EOF.
|
||||||
|
d.frameNotifs = make(chan bool, 1)
|
||||||
|
if d.output == nil {
|
||||||
|
d.Fatal("need an output reader to be notified of frames")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(d.output)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
d.Log(line)
|
||||||
|
if strings.Contains(line, "gio frame ready") {
|
||||||
|
d.frameNotifs <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Since we're only interested in the output while the
|
||||||
|
// app runs, and we don't know when it finishes here,
|
||||||
|
// ignore "already closed" pipe errors.
|
||||||
|
if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||||
|
d.Errorf("reading app output: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately, there isn't a way to select on a test failing, since
|
||||||
|
// testing.T doesn't have anything like a context or a "done" channel.
|
||||||
|
//
|
||||||
|
// We can't let selects block forever, since the default -test.timeout
|
||||||
|
// is ten minutes - far too long for tests that take seconds.
|
||||||
|
//
|
||||||
|
// For now, a static short timeout is better than nothing. 5s is plenty
|
||||||
|
// for our simple test app to render on any device.
|
||||||
|
select {
|
||||||
|
case <-d.frameNotifs:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
d.Fatalf("timed out waiting for a frame to be ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) needPrograms(names ...string) {
|
||||||
|
d.Helper()
|
||||||
|
for _, name := range names {
|
||||||
|
if _, err := exec.LookPath(name); err != nil {
|
||||||
|
d.Skipf("%s needed to run", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) tempDir(name string) string {
|
||||||
|
d.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", name)
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
d.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *driverBase) gogio(args ...string) {
|
||||||
|
d.Helper()
|
||||||
|
prog, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(prog, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
d.Fatalf("gogio error: %s:\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
gogio -target <target> [flags] <package> [run arguments]
|
||||||
|
|
||||||
|
The gogio tool builds and packages Gio programs for platforms where additional
|
||||||
|
metadata or support files are required.
|
||||||
|
|
||||||
|
The package argument specifies an import path or a single Go source file to
|
||||||
|
package. Any run arguments are appended to os.Args at runtime.
|
||||||
|
|
||||||
|
Compiled Java class files from jar files in the package directory are
|
||||||
|
included in Android builds.
|
||||||
|
|
||||||
|
The mandatory -target flag selects the target platform: ios or android for the
|
||||||
|
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
|
||||||
|
MacOS and windows for Windows.
|
||||||
|
|
||||||
|
The -arch flag specifies a comma separated list of GOARCHs to include. The
|
||||||
|
default is all supported architectures.
|
||||||
|
|
||||||
|
The -o flag specifies an output file or directory, depending on the target.
|
||||||
|
|
||||||
|
The -buildmode flag selects the build mode. Two build modes are available, exe
|
||||||
|
and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
|
||||||
|
for Android or a directory with the WebAssembly module and support files for
|
||||||
|
a browser.
|
||||||
|
|
||||||
|
The -ldflags and -tags flags pass extra linker flags and tags to the go tool.
|
||||||
|
|
||||||
|
As a special case for iOS or tvOS, specifying a path that ends with ".app"
|
||||||
|
will output an app directory suitable for a simulator.
|
||||||
|
|
||||||
|
The other buildmode is archive, which will output an .aar library for Android
|
||||||
|
or a .framework for iOS and tvOS.
|
||||||
|
|
||||||
|
The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
|
||||||
|
If left unspecified, the appicon.png file from the main package is used
|
||||||
|
(if it exists).
|
||||||
|
|
||||||
|
The -appid flag specifies the package name for Android or the bundle id for
|
||||||
|
iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
|
||||||
|
tool can use it.
|
||||||
|
|
||||||
|
The -version flag specifies the integer version code for Android and the last
|
||||||
|
component of the 1.0.X version for iOS and tvOS.
|
||||||
|
|
||||||
|
For Android builds the -minsdk flag specify the minimum SDK level. For example,
|
||||||
|
use -minsdk 22 to target Android 5.1 (Lollipop) and later.
|
||||||
|
|
||||||
|
For Windows builds the -minsdk flag specify the minimum OS version. For example,
|
||||||
|
use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.
|
||||||
|
|
||||||
|
For iOS builds the -minsdk flag specify the minimum iOS version. For example,
|
||||||
|
use -mindk 15 to target iOS 15.0 and later.
|
||||||
|
|
||||||
|
For Android builds the -targetsdk flag specify the target SDK level. For example,
|
||||||
|
use -targetsdk 33 to target Android 13 (Tiramisu) and later.
|
||||||
|
|
||||||
|
The -work flag prints the path to the working directory and suppress
|
||||||
|
its deletion.
|
||||||
|
|
||||||
|
The -x flag will print all the external commands executed by the gogio tool.
|
||||||
|
|
||||||
|
The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
|
||||||
|
or specifies the name of key on Keychain to sign MacOS app.
|
||||||
|
|
||||||
|
The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
|
||||||
|
|
||||||
|
The -notaryid flag specifies the Apple ID to use for notarization of MacOS app.
|
||||||
|
|
||||||
|
The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not
|
||||||
|
provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397
|
||||||
|
for details. If not provided, the password will be prompted.
|
||||||
|
|
||||||
|
The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if
|
||||||
|
-notaryid is not provided.
|
||||||
|
`
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
//go:build linux
|
||||||
|
// +build linux
|
||||||
|
|
||||||
|
// This program demonstrates the use of a custom OpenGL ES context with
|
||||||
|
// app.Window.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/gpu"
|
||||||
|
"gioui.org/io/event"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo linux pkg-config: egl wayland-egl
|
||||||
|
#cgo freebsd openbsd CFLAGS: -I/usr/local/include
|
||||||
|
#cgo openbsd CFLAGS: -I/usr/X11R6/include
|
||||||
|
#cgo freebsd LDFLAGS: -L/usr/local/lib
|
||||||
|
#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
|
||||||
|
#cgo freebsd openbsd LDFLAGS: -lwayland-egl
|
||||||
|
#cgo CFLAGS: -DEGL_NO_X11
|
||||||
|
#cgo LDFLAGS: -lEGL -lGLESv2
|
||||||
|
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <wayland-client.h>
|
||||||
|
#include <wayland-egl.h>
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
#define EGL_EGLEXT_PROTOTYPES
|
||||||
|
#include <EGL/eglext.h>
|
||||||
|
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
func getDisplay(ve app.ViewEvent) C.EGLDisplay {
|
||||||
|
switch ve := ve.(type) {
|
||||||
|
case app.X11ViewEvent:
|
||||||
|
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||||
|
case app.WaylandViewEvent:
|
||||||
|
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||||
|
}
|
||||||
|
panic("no display available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
|
||||||
|
switch e := e.(type) {
|
||||||
|
case app.X11ViewEvent:
|
||||||
|
return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
|
||||||
|
case app.WaylandViewEvent:
|
||||||
|
eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
|
||||||
|
return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
|
||||||
|
C.wl_egl_window_destroy(eglWin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("no native view available")
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
C = layout.Context
|
||||||
|
D = layout.Dimensions
|
||||||
|
)
|
||||||
|
|
||||||
|
type notifyFrame int
|
||||||
|
|
||||||
|
const (
|
||||||
|
notifyNone notifyFrame = iota
|
||||||
|
notifyInvalidate
|
||||||
|
notifyPrint
|
||||||
|
)
|
||||||
|
|
||||||
|
// notify keeps track of whether we want to print to stdout to notify the user
|
||||||
|
// when a frame is ready. Initially we want to notify about the first frame.
|
||||||
|
var notify = notifyInvalidate
|
||||||
|
|
||||||
|
type eglContext struct {
|
||||||
|
disp C.EGLDisplay
|
||||||
|
ctx C.EGLContext
|
||||||
|
surf C.EGLSurface
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
// Set CustomRenderer so we can provide our own rendering context.
|
||||||
|
w := new(app.Window)
|
||||||
|
w.Option(app.CustomRenderer(true))
|
||||||
|
if err := loop(w); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loop(w *app.Window) error {
|
||||||
|
var ops op.Ops
|
||||||
|
var (
|
||||||
|
ctx *eglContext
|
||||||
|
gioCtx gpu.GPU
|
||||||
|
ve app.ViewEvent
|
||||||
|
init bool
|
||||||
|
size image.Point
|
||||||
|
)
|
||||||
|
|
||||||
|
recreateContext := func() {
|
||||||
|
w.Run(func() {
|
||||||
|
if gioCtx != nil {
|
||||||
|
gioCtx.Release()
|
||||||
|
gioCtx = nil
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
C.eglMakeCurrent(ctx.disp, nil, nil, nil)
|
||||||
|
ctx.Release()
|
||||||
|
ctx = nil
|
||||||
|
}
|
||||||
|
c, err := createContext(ve, size)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx = c
|
||||||
|
})
|
||||||
|
if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
|
||||||
|
err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
glGetString := func(e C.GLenum) string {
|
||||||
|
return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
|
||||||
|
}
|
||||||
|
fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
|
||||||
|
var err error
|
||||||
|
gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||||
|
}
|
||||||
|
topRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||||
|
}
|
||||||
|
botLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||||
|
}
|
||||||
|
botRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||||
|
}
|
||||||
|
|
||||||
|
// eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
|
||||||
|
runtime.LockOSThread()
|
||||||
|
for {
|
||||||
|
switch e := w.Event().(type) {
|
||||||
|
case app.ViewEvent:
|
||||||
|
ve = e
|
||||||
|
init = true
|
||||||
|
if size != (image.Point{}) {
|
||||||
|
recreateContext()
|
||||||
|
}
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return e.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
if init && size != e.Size {
|
||||||
|
size = e.Size
|
||||||
|
recreateContext()
|
||||||
|
}
|
||||||
|
if gioCtx == nil || !init {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Build ops.
|
||||||
|
gtx := app.NewContext(&ops, e)
|
||||||
|
|
||||||
|
// Clear background to white, even on embedded platforms such as webassembly.
|
||||||
|
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||||
|
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r1c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||||
|
// r1c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r2c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||||
|
// r2c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
gtx.Execute(op.InvalidateCmd{})
|
||||||
|
log.Println("frame")
|
||||||
|
|
||||||
|
// Trigger window resize detection in ANGLE.
|
||||||
|
C.eglWaitClient()
|
||||||
|
// Draw custom OpenGL content.
|
||||||
|
drawGL()
|
||||||
|
|
||||||
|
// Render drawing ops.
|
||||||
|
if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
|
||||||
|
log.Fatal(fmt.Errorf("render failed: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process non-drawing ops.
|
||||||
|
e.Frame(gtx.Ops)
|
||||||
|
switch notify {
|
||||||
|
case notifyInvalidate:
|
||||||
|
notify = notifyPrint
|
||||||
|
w.Invalidate()
|
||||||
|
case notifyPrint:
|
||||||
|
notify = notifyNone
|
||||||
|
fmt.Println("gio frame ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
|
||||||
|
log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawGL() {
|
||||||
|
C.glClearColor(0, 0, 0, 1)
|
||||||
|
C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
|
||||||
|
view, cleanup := nativeViewFor(ve, size)
|
||||||
|
var nilv C.EGLNativeWindowType
|
||||||
|
if view == nilv {
|
||||||
|
return nil, fmt.Errorf("failed creating native view")
|
||||||
|
}
|
||||||
|
disp := getDisplay(ve)
|
||||||
|
if disp == 0 {
|
||||||
|
return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
var major, minor C.EGLint
|
||||||
|
if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
|
||||||
|
return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
|
||||||
|
srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
|
||||||
|
attribs := []C.EGLint{
|
||||||
|
C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
|
||||||
|
C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
|
||||||
|
C.EGL_BLUE_SIZE, 8,
|
||||||
|
C.EGL_GREEN_SIZE, 8,
|
||||||
|
C.EGL_RED_SIZE, 8,
|
||||||
|
C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
|
||||||
|
}
|
||||||
|
if srgb {
|
||||||
|
// Some drivers need alpha for sRGB framebuffers to work.
|
||||||
|
attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
|
||||||
|
}
|
||||||
|
attribs = append(attribs, C.EGL_NONE)
|
||||||
|
var (
|
||||||
|
cfg C.EGLConfig
|
||||||
|
numCfgs C.EGLint
|
||||||
|
)
|
||||||
|
if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
|
||||||
|
return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
if numCfgs == 0 {
|
||||||
|
supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
|
||||||
|
if !supportsNoCfg {
|
||||||
|
return nil, errors.New("eglChooseConfig returned no configs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctxAttribs := []C.EGLint{
|
||||||
|
C.EGL_CONTEXT_CLIENT_VERSION, 3,
|
||||||
|
C.EGL_NONE,
|
||||||
|
}
|
||||||
|
ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
|
||||||
|
if ctx == nil {
|
||||||
|
return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
|
||||||
|
}
|
||||||
|
var surfAttribs []C.EGLint
|
||||||
|
if srgb {
|
||||||
|
surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
|
||||||
|
}
|
||||||
|
surfAttribs = append(surfAttribs, C.EGL_NONE)
|
||||||
|
surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
|
||||||
|
if surf == nil {
|
||||||
|
return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
|
||||||
|
}
|
||||||
|
return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *eglContext) Release() {
|
||||||
|
if c.ctx != nil {
|
||||||
|
C.eglDestroyContext(c.disp, c.ctx)
|
||||||
|
}
|
||||||
|
if c.surf != nil {
|
||||||
|
C.eglDestroySurface(c.disp, c.surf)
|
||||||
|
}
|
||||||
|
if c.cleanup != nil {
|
||||||
|
c.cleanup()
|
||||||
|
}
|
||||||
|
*c = eglContext{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExtension(exts []string, ext string) bool {
|
||||||
|
for _, e := range exts {
|
||||||
|
if ext == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||||
|
// turns red, going back to its normal color when clicked again.
|
||||||
|
type quarterWidget struct {
|
||||||
|
color color.NRGBA
|
||||||
|
|
||||||
|
clicked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
var color color.NRGBA
|
||||||
|
if w.clicked {
|
||||||
|
color = red
|
||||||
|
} else {
|
||||||
|
color = w.color
|
||||||
|
}
|
||||||
|
|
||||||
|
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||||
|
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||||
|
|
||||||
|
defer clip.Rect(image.Rectangle{
|
||||||
|
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||||
|
}).Push(gtx.Ops).Pop()
|
||||||
|
event.Op(gtx.Ops, w)
|
||||||
|
for {
|
||||||
|
e, ok := gtx.Event(pointer.Filter{
|
||||||
|
Target: w,
|
||||||
|
Kinds: pointer.Press,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||||
|
w.clicked = !w.clicked
|
||||||
|
// notify when we're done updating the frame.
|
||||||
|
notify = notifyInvalidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
// A simple app used for gogio's end-to-end tests.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/io/event"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
w := new(app.Window)
|
||||||
|
if err := loop(w); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
type notifyFrame int
|
||||||
|
|
||||||
|
const (
|
||||||
|
notifyNone notifyFrame = iota
|
||||||
|
notifyInvalidate
|
||||||
|
notifyPrint
|
||||||
|
)
|
||||||
|
|
||||||
|
// notify keeps track of whether we want to print to stdout to notify the user
|
||||||
|
// when a frame is ready. Initially we want to notify about the first frame.
|
||||||
|
var notify = notifyInvalidate
|
||||||
|
|
||||||
|
type (
|
||||||
|
C = layout.Context
|
||||||
|
D = layout.Dimensions
|
||||||
|
)
|
||||||
|
|
||||||
|
func loop(w *app.Window) error {
|
||||||
|
topLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||||
|
}
|
||||||
|
topRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||||
|
}
|
||||||
|
botLeft := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||||
|
}
|
||||||
|
botRight := quarterWidget{
|
||||||
|
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ops op.Ops
|
||||||
|
for {
|
||||||
|
e := w.Event()
|
||||||
|
switch e := e.(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return e.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
gtx := app.NewContext(&ops, e)
|
||||||
|
// Clear background to white, even on embedded platforms such as webassembly.
|
||||||
|
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||||
|
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r1c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||||
|
// r1c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
|
// r2c1
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||||
|
// r2c2
|
||||||
|
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
e.Frame(gtx.Ops)
|
||||||
|
|
||||||
|
switch notify {
|
||||||
|
case notifyInvalidate:
|
||||||
|
notify = notifyPrint
|
||||||
|
w.Invalidate()
|
||||||
|
case notifyPrint:
|
||||||
|
notify = notifyNone
|
||||||
|
fmt.Println("gio frame ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||||
|
// turns red, going back to its normal color when clicked again.
|
||||||
|
type quarterWidget struct {
|
||||||
|
color color.NRGBA
|
||||||
|
|
||||||
|
clicked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||||
|
|
||||||
|
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
var color color.NRGBA
|
||||||
|
if w.clicked {
|
||||||
|
color = red
|
||||||
|
} else {
|
||||||
|
color = w.color
|
||||||
|
}
|
||||||
|
|
||||||
|
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||||
|
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||||
|
|
||||||
|
defer clip.Rect(image.Rectangle{
|
||||||
|
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||||
|
}).Push(gtx.Ops).Pop()
|
||||||
|
event.Op(gtx.Ops, w)
|
||||||
|
filter := pointer.Filter{
|
||||||
|
Target: w,
|
||||||
|
Kinds: pointer.Press,
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
e, ok := gtx.Event(filter)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||||
|
w.clicked = !w.clicked
|
||||||
|
// notify when we're done updating the frame.
|
||||||
|
notify = notifyInvalidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minIOSVersion = 10
|
||||||
|
// Some Metal features require tvOS 11
|
||||||
|
minTVOSVersion = 11
|
||||||
|
// Metal is available from iOS 8 on devices, yet from version 13 on the
|
||||||
|
// simulator.
|
||||||
|
minSimulatorVersion = 13
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildIOS(tmpDir, target string, bi *buildInfo) error {
|
||||||
|
appName := bi.name
|
||||||
|
switch *buildMode {
|
||||||
|
case "archive":
|
||||||
|
framework := *destPath
|
||||||
|
if framework == "" {
|
||||||
|
framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
|
||||||
|
}
|
||||||
|
return archiveIOS(tmpDir, target, framework, bi)
|
||||||
|
case "exe":
|
||||||
|
out := *destPath
|
||||||
|
if out == "" {
|
||||||
|
out = appName + ".ipa"
|
||||||
|
}
|
||||||
|
forDevice := strings.HasSuffix(out, ".ipa")
|
||||||
|
// Filter out unsupported architectures.
|
||||||
|
for i := len(bi.archs) - 1; i >= 0; i-- {
|
||||||
|
switch bi.archs[i] {
|
||||||
|
case "arm", "arm64":
|
||||||
|
if forDevice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "386", "amd64":
|
||||||
|
if !forDevice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
|
||||||
|
}
|
||||||
|
if !forDevice && !strings.HasSuffix(out, ".app") {
|
||||||
|
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
|
||||||
|
}
|
||||||
|
if !forDevice {
|
||||||
|
return exeIOS(tmpDir, target, out, bi)
|
||||||
|
}
|
||||||
|
payload := filepath.Join(tmpDir, "Payload")
|
||||||
|
appDir := filepath.Join(payload, appName+".app")
|
||||||
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := signIOS(bi, tmpDir, appDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zipDir(out, tmpDir, "Payload")
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIOS(bi *buildInfo, tmpDir, app string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
|
||||||
|
provisions, err := filepath.Glob(provPattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provInfo := filepath.Join(tmpDir, "provision.plist")
|
||||||
|
var avail []string
|
||||||
|
for _, prov := range provisions {
|
||||||
|
// Decode the provision file to a plist.
|
||||||
|
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exp, err := time.Parse(time.UnixDate, expUnix)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
|
||||||
|
}
|
||||||
|
if exp.Before(time.Now()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
|
||||||
|
avail = append(avail, provAppID)
|
||||||
|
if expAppID != provAppID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Copy provisioning file.
|
||||||
|
embedded := filepath.Join(app, "embedded.mobileprovision")
|
||||||
|
if err := copyFile(embedded, prov); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Omit trailing newline.
|
||||||
|
certDER = certDER[:len(certDER)-1]
|
||||||
|
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entFile := filepath.Join(tmpDir, "entitlements.plist")
|
||||||
|
if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity := sha1.Sum(certDER)
|
||||||
|
idHex := hex.EncodeToString(identity[:])
|
||||||
|
_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
|
||||||
|
if bi.appID == "" {
|
||||||
|
return errors.New("app id is empty; use -appid to set it")
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(app, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
appName := UppercaseName(bi.name)
|
||||||
|
exe := filepath.Join(app, appName)
|
||||||
|
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||||
|
var builds errgroup.Group
|
||||||
|
for _, a := range bi.archs {
|
||||||
|
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cflags = append(cflags,
|
||||||
|
"-fobjc-arc",
|
||||||
|
)
|
||||||
|
cflagsLine := strings.Join(cflags, " ")
|
||||||
|
exeSlice := filepath.Join(tmpDir, "app-"+a)
|
||||||
|
lipo.Args = append(lipo.Args, exeSlice)
|
||||||
|
compile := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags=-s -w "+bi.ldflags,
|
||||||
|
"-o", exeSlice,
|
||||||
|
"-tags", bi.tags,
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
compile.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=ios",
|
||||||
|
"GOARCH="+a,
|
||||||
|
"CGO_ENABLED=1",
|
||||||
|
"CC="+clang,
|
||||||
|
"CGO_CFLAGS="+cflagsLine,
|
||||||
|
"CGO_LDFLAGS=-lresolv "+cflagsLine,
|
||||||
|
)
|
||||||
|
builds.Go(func() error {
|
||||||
|
_, err := runCmd(compile)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := builds.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := runCmd(lipo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
infoPlist := buildInfoPlist(bi)
|
||||||
|
plistFile := filepath.Join(app, "Info.plist")
|
||||||
|
if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||||
|
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Merge assets plist with Info.plist
|
||||||
|
cmd := exec.Command(
|
||||||
|
"/usr/libexec/PlistBuddy",
|
||||||
|
"-c", "Merge "+assetPlist,
|
||||||
|
plistFile,
|
||||||
|
)
|
||||||
|
if _, err := runCmd(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
|
||||||
|
// iosIcons returns the asset plist file to be merged into Info.plist.
|
||||||
|
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
|
||||||
|
assets := filepath.Join(tmpDir, "Assets.xcassets")
|
||||||
|
if err := os.Mkdir(assets, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
appIcon := filepath.Join(assets, "AppIcon.appiconset")
|
||||||
|
err := buildIcons(appIcon, icon, []iconVariant{
|
||||||
|
{path: "ios_2x.png", size: 120},
|
||||||
|
{path: "ios_3x.png", size: 180},
|
||||||
|
// The App Store icon is not allowed to contain
|
||||||
|
// transparent pixels.
|
||||||
|
{path: "ios_store.png", size: 1024, fill: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
contentJson := `{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "ios_2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "ios_3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "ios_store.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
contentFile := filepath.Join(appIcon, "Contents.json")
|
||||||
|
if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assetPlist := filepath.Join(tmpDir, "assets.plist")
|
||||||
|
|
||||||
|
minsdk := bi.minsdk
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minIOSVersion
|
||||||
|
}
|
||||||
|
compile := exec.Command(
|
||||||
|
"actool",
|
||||||
|
"--compile", appDir,
|
||||||
|
"--platform", iosPlatformFor(bi.target),
|
||||||
|
"--minimum-deployment-target", strconv.Itoa(minsdk),
|
||||||
|
"--app-icon", "AppIcon",
|
||||||
|
"--output-partial-info-plist", assetPlist,
|
||||||
|
assets)
|
||||||
|
_, err = runCmd(compile)
|
||||||
|
return assetPlist, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfoPlist(bi *buildInfo) string {
|
||||||
|
appName := UppercaseName(bi.name)
|
||||||
|
platform := iosPlatformFor(bi.target)
|
||||||
|
var supportPlatform string
|
||||||
|
switch bi.target {
|
||||||
|
case "ios":
|
||||||
|
supportPlatform = "iPhoneOS"
|
||||||
|
case "tvos":
|
||||||
|
supportPlatform = "AppleTVOS"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>%d</string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array><string>arm64</string></array>
|
||||||
|
<key>DTPlatformName</key>
|
||||||
|
<string>%s</string>
|
||||||
|
<key>DTPlatformVersion</key>
|
||||||
|
<string>12.4</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>%d</string>
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<integer>1</integer>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleSupportedPlatforms</key>
|
||||||
|
<array>
|
||||||
|
<string>%s</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>DTCompiler</key>
|
||||||
|
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||||
|
<key>DTPlatformBuild</key>
|
||||||
|
<string>16G73</string>
|
||||||
|
<key>DTSDKBuild</key>
|
||||||
|
<string>16G73</string>
|
||||||
|
<key>DTSDKName</key>
|
||||||
|
<string>%s12.4</string>
|
||||||
|
<key>DTXcode</key>
|
||||||
|
<string>1030</string>
|
||||||
|
<key>DTXcodeBuild</key>
|
||||||
|
<string>10G8</string>
|
||||||
|
</dict>
|
||||||
|
</plist>`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iosPlatformFor(target string) string {
|
||||||
|
switch target {
|
||||||
|
case "ios":
|
||||||
|
return "iphoneos"
|
||||||
|
case "tvos":
|
||||||
|
return "appletvos"
|
||||||
|
default:
|
||||||
|
panic("invalid platform " + target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
|
||||||
|
framework := filepath.Base(frameworkRoot)
|
||||||
|
const suf = ".framework"
|
||||||
|
if !strings.HasSuffix(framework, suf) {
|
||||||
|
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
|
||||||
|
}
|
||||||
|
framework = framework[:len(framework)-len(suf)]
|
||||||
|
if err := os.RemoveAll(frameworkRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
|
||||||
|
for _, dir := range []string{"Headers", "Modules"} {
|
||||||
|
p := filepath.Join(frameworkDir, dir)
|
||||||
|
if err := os.MkdirAll(p, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
symlinks := [][2]string{
|
||||||
|
{"Versions/Current/Headers", "Headers"},
|
||||||
|
{"Versions/Current/Modules", "Modules"},
|
||||||
|
{"Versions/Current/" + framework, framework},
|
||||||
|
{"A", filepath.Join("Versions", "Current")},
|
||||||
|
}
|
||||||
|
for _, l := range symlinks {
|
||||||
|
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exe := filepath.Join(frameworkDir, framework)
|
||||||
|
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||||
|
var builds errgroup.Group
|
||||||
|
tags := bi.tags
|
||||||
|
for _, a := range bi.archs {
|
||||||
|
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lib := filepath.Join(tmpDir, "gio-"+a)
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags=-s -w "+bi.ldflags,
|
||||||
|
"-buildmode=c-archive",
|
||||||
|
"-o", lib,
|
||||||
|
"-tags", tags,
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
lipo.Args = append(lipo.Args, lib)
|
||||||
|
cflagsLine := strings.Join(cflags, " ")
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=ios",
|
||||||
|
"GOARCH="+a,
|
||||||
|
"CGO_ENABLED=1",
|
||||||
|
"CC="+clang,
|
||||||
|
"CGO_CFLAGS="+cflagsLine,
|
||||||
|
"CGO_LDFLAGS="+cflagsLine,
|
||||||
|
)
|
||||||
|
builds.Go(func() error {
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := builds.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := runCmd(lipo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
|
||||||
|
headerSrc := filepath.Join(appDir, "framework_ios.h")
|
||||||
|
if err := copyFile(headerDst, headerSrc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
module := fmt.Sprintf(`framework module "%s" {
|
||||||
|
header "%[1]s.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
}`, framework)
|
||||||
|
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
|
||||||
|
return os.WriteFile(moduleFile, []byte(module), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
|
||||||
|
var (
|
||||||
|
platformSDK string
|
||||||
|
platformOS string
|
||||||
|
)
|
||||||
|
switch target {
|
||||||
|
case "ios":
|
||||||
|
platformOS = "ios"
|
||||||
|
platformSDK = "iphone"
|
||||||
|
case "tvos":
|
||||||
|
platformOS = "tvos"
|
||||||
|
platformSDK = "appletv"
|
||||||
|
}
|
||||||
|
switch arch {
|
||||||
|
case "arm", "arm64":
|
||||||
|
platformSDK += "os"
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minIOSVersion
|
||||||
|
if target == "tvos" {
|
||||||
|
minsdk = minTVOSVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "386", "amd64":
|
||||||
|
platformOS += "-simulator"
|
||||||
|
platformSDK += "simulator"
|
||||||
|
if minsdk == 0 {
|
||||||
|
minsdk = minSimulatorVersion
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
|
||||||
|
}
|
||||||
|
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
cflags := []string{
|
||||||
|
"-fembed-bitcode",
|
||||||
|
"-arch", allArchs[arch].iosArch,
|
||||||
|
"-isysroot", sdkPath,
|
||||||
|
"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
|
||||||
|
}
|
||||||
|
return clang, cflags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipDir(dst, base, dir string) (err error) {
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
zipf := zip.NewWriter(f)
|
||||||
|
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel := filepath.ToSlash(path[len(base)+1:])
|
||||||
|
entry, err := zipf.Create(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
_, err = io.Copy(entry, src)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zipf.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
|
||||||
|
)
|
||||||
|
|
||||||
|
type JSTestDriver struct {
|
||||||
|
driverBase
|
||||||
|
|
||||||
|
// ctx is the chromedp context.
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Start(path string) {
|
||||||
|
if raceEnabled {
|
||||||
|
d.Skipf("js/wasm doesn't support -race; skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, build the app.
|
||||||
|
dir := d.tempDir("gio-endtoend-js")
|
||||||
|
d.gogio("-target=js", "-o="+dir, path)
|
||||||
|
|
||||||
|
// Second, start Chrome.
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.Flag("headless", *headless),
|
||||||
|
)
|
||||||
|
|
||||||
|
actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
|
||||||
|
ctx, cancel := chromedp.NewContext(actx,
|
||||||
|
// Send all logf/errf calls to t.Logf
|
||||||
|
chromedp.WithLogf(d.Logf),
|
||||||
|
)
|
||||||
|
d.Cleanup(cancel)
|
||||||
|
d.ctx = ctx
|
||||||
|
|
||||||
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
d.Skipf("test requires Chrome to be installed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
d.Cleanup(func() { pw.Close() })
|
||||||
|
d.output = pr
|
||||||
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
|
switch ev := ev.(type) {
|
||||||
|
case *runtime.EventConsoleAPICalled:
|
||||||
|
switch ev.Type {
|
||||||
|
case "log", "info", "warning", "error":
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString("console.")
|
||||||
|
b.WriteString(string(ev.Type))
|
||||||
|
b.WriteString("(")
|
||||||
|
for i, arg := range ev.Args {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(", ")
|
||||||
|
}
|
||||||
|
b.Write(arg.Value)
|
||||||
|
}
|
||||||
|
b.WriteString(")\n")
|
||||||
|
pw.Write(b.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Third, serve the app folder, set the browser tab dimensions, and
|
||||||
|
// navigate to the folder.
|
||||||
|
ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
|
||||||
|
d.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
if err := chromedp.Run(ctx,
|
||||||
|
chromedp.EmulateViewport(int64(d.width), int64(d.height)),
|
||||||
|
chromedp.Navigate(ts.URL),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Screenshot() image.Image {
|
||||||
|
var buf []byte
|
||||||
|
if err := chromedp.Run(d.ctx,
|
||||||
|
chromedp.CaptureScreenshot(&buf),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *JSTestDriver) Click(x, y int) {
|
||||||
|
if err := chromedp.Run(d.ctx,
|
||||||
|
chromedp.MouseClickXY(float64(x), float64(y)),
|
||||||
|
); err != nil {
|
||||||
|
d.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the gio app to render after this click.
|
||||||
|
d.waitForFrame()
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"golang.org/x/tools/go/packages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildJS(bi *buildInfo) error {
|
||||||
|
out := *destPath
|
||||||
|
if out == "" {
|
||||||
|
out = bi.name
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(out, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags="+bi.ldflags,
|
||||||
|
"-tags="+bi.tags,
|
||||||
|
"-o", filepath.Join(out, "main.wasm"),
|
||||||
|
bi.pkgPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=js",
|
||||||
|
"GOARCH=wasm",
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var faviconPath string
|
||||||
|
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||||
|
// Copy icon to the output folder
|
||||||
|
icon, err := os.ReadFile(bi.iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
faviconPath = filepath.Base(bi.iconPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexTemplate, err := template.New("").Parse(jsIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := indexTemplate.Execute(&b, struct {
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
}{
|
||||||
|
Name: bi.name,
|
||||||
|
Icon: faviconPath,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
|
||||||
|
if _, err := os.Stat(wasmJS); err != nil {
|
||||||
|
return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
|
||||||
|
}
|
||||||
|
pkgs, err := packages.Load(&packages.Config{
|
||||||
|
Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
|
||||||
|
Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
|
||||||
|
}, bi.pkgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
|
||||||
|
if len(p.GoFiles) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
extraJS = append(extraJS, js...)
|
||||||
|
for _, imp := range p.Imports {
|
||||||
|
if !visited[imp.ID] {
|
||||||
|
extra, err := findPackagesJS(imp, visited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
extraJS = append(extraJS, extra...)
|
||||||
|
visited[imp.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extraJS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
|
||||||
|
// and append the jsStartGo.
|
||||||
|
func mergeJSFiles(dst string, files ...string) (err error) {
|
||||||
|
w, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := w.Close(); err != nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = io.Copy(w, strings.NewReader(jsSetGo))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range files {
|
||||||
|
r, err := os.Open(files[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
r.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, strings.NewReader(jsStartGo))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
jsIndex = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
{{ if .Icon }}<link rel="icon" href="{{.Icon}}" type="image/x-icon" />{{ end }}
|
||||||
|
{{ if .Name }}<title>{{.Name}}</title>{{ end }}
|
||||||
|
<script src="wasm.js"></script>
|
||||||
|
<style>
|
||||||
|
body,pre { margin:0;padding:0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
// jsSetGo sets the `window.go` variable.
|
||||||
|
jsSetGo = `(() => {
|
||||||
|
window.go = {argv: [], env: {}, importObject: {go: {}}};
|
||||||
|
const argv = new URLSearchParams(location.search).get("argv");
|
||||||
|
if (argv) {
|
||||||
|
window.go["argv"] = argv.split(" ");
|
||||||
|
}
|
||||||
|
})();`
|
||||||
|
// jsStartGo initializes the main.wasm.
|
||||||
|
jsStartGo = `(() => {
|
||||||
|
defaultGo = new Go();
|
||||||
|
Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
|
||||||
|
Object.assign(defaultGo["env"], go["env"]);
|
||||||
|
for (let key in go["importObject"]) {
|
||||||
|
if (typeof defaultGo["importObject"][key] === "undefined") {
|
||||||
|
defaultGo["importObject"][key] = {};
|
||||||
|
}
|
||||||
|
Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
|
||||||
|
}
|
||||||
|
window.go = defaultGo;
|
||||||
|
if (!WebAssembly.instantiateStreaming) { // polyfill
|
||||||
|
WebAssembly.instantiateStreaming = async (resp, importObject) => {
|
||||||
|
const source = await (await resp).arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(source, importObject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
|
||||||
|
go.run(result.instance);
|
||||||
|
});
|
||||||
|
})();`
|
||||||
|
)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildMac(tmpDir string, bi *buildInfo) error {
|
||||||
|
builder := &macBuilder{TempDir: tmpDir}
|
||||||
|
builder.DestDir = *destPath
|
||||||
|
if builder.DestDir == "" {
|
||||||
|
builder.DestDir = bi.pkgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
name := bi.name
|
||||||
|
if *destPath != "" {
|
||||||
|
if filepath.Ext(*destPath) != ".app" {
|
||||||
|
return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
|
||||||
|
}
|
||||||
|
name = filepath.Base(*destPath)
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, ".app")
|
||||||
|
|
||||||
|
if bi.appID == "" {
|
||||||
|
return errors.New("app id is empty; use -appid to set it")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.setIcon(bi.iconPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.setInfo(bi, name); err != nil {
|
||||||
|
return fmt.Errorf("can't build the resources: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arch := range bi.archs {
|
||||||
|
tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir))
|
||||||
|
finalDest := builder.DestDir
|
||||||
|
if len(bi.archs) > 1 {
|
||||||
|
tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app")
|
||||||
|
finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bi.key != "" {
|
||||||
|
if err := builder.signProgram(bi, tmpDest, name, arch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dittozip(tmpDest, tmpDest+".zip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if bi.notaryAppleID != "" {
|
||||||
|
if err := builder.notarize(bi, tmpDest+".zip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dittounzip(tmpDest+".zip", finalDest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type macBuilder struct {
|
||||||
|
TempDir string
|
||||||
|
DestDir string
|
||||||
|
|
||||||
|
Icons []byte
|
||||||
|
Manifest []byte
|
||||||
|
Entitlements []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) setIcon(path string) (err error) {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(b.TempDir, "iconset.iconset")
|
||||||
|
if err := os.MkdirAll(out, 0777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = buildIcons(out, path, []iconVariant{
|
||||||
|
{path: "icon_512x512@2x.png", size: 1024},
|
||||||
|
{path: "icon_512x512.png", size: 512},
|
||||||
|
{path: "icon_256x256@2x.png", size: 512},
|
||||||
|
{path: "icon_256x256.png", size: 256},
|
||||||
|
{path: "icon_128x128@2x.png", size: 256},
|
||||||
|
{path: "icon_128x128.png", size: 128},
|
||||||
|
{path: "icon_64x64@2x.png", size: 128},
|
||||||
|
{path: "icon_64x64.png", size: 64},
|
||||||
|
{path: "icon_32x32@2x.png", size: 64},
|
||||||
|
{path: "icon_32x32.png", size: 32},
|
||||||
|
{path: "icon_16x16@2x.png", size: 32},
|
||||||
|
{path: "icon_16x16.png", size: 16},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("iconutil",
|
||||||
|
"-c", "icns", out,
|
||||||
|
"-o", filepath.Join(b.TempDir, "icon.icns"))
|
||||||
|
if _, err := runCmd(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
|
||||||
|
t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icon.icns</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>{{.Bundle}}</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest bufferCoff
|
||||||
|
if err := t.Execute(&manifest, struct {
|
||||||
|
Name, Bundle string
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
Bundle: buildInfo.appID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.Manifest = manifest.Bytes()
|
||||||
|
|
||||||
|
b.Entitlements = []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>`)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
|
||||||
|
for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b.Icons) > 0 {
|
||||||
|
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"go",
|
||||||
|
"build",
|
||||||
|
"-ldflags="+buildInfo.ldflags,
|
||||||
|
"-tags="+buildInfo.tags,
|
||||||
|
"-o", filepath.Join(binDest, "/Contents/MacOS/"+name),
|
||||||
|
buildInfo.pkgPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"GOOS=darwin",
|
||||||
|
"GOARCH="+arch,
|
||||||
|
"CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
|
||||||
|
options := filepath.Join(b.TempDir, "ent.ent")
|
||||||
|
if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
xattr := exec.Command("xattr", "-rc", binDest)
|
||||||
|
if _, err := runCmd(xattr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"codesign",
|
||||||
|
"--deep",
|
||||||
|
"--force",
|
||||||
|
"--options", "runtime",
|
||||||
|
"--entitlements", options,
|
||||||
|
"--sign", buildInfo.key,
|
||||||
|
binDest,
|
||||||
|
)
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"xcrun",
|
||||||
|
"notarytool",
|
||||||
|
"submit",
|
||||||
|
binDest,
|
||||||
|
"--apple-id", buildInfo.notaryAppleID,
|
||||||
|
"--team-id", buildInfo.notaryTeamID,
|
||||||
|
"--wait",
|
||||||
|
)
|
||||||
|
|
||||||
|
if buildInfo.notaryPassword != "" {
|
||||||
|
cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dittozip(input, output string) error {
|
||||||
|
cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output)
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dittounzip(input, output string) error {
|
||||||
|
cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output)
|
||||||
|
|
||||||
|
_, err := runCmd(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
|
||||||
|
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
|
||||||
|
minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
|
||||||
|
targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android")
|
||||||
|
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
|
||||||
|
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
|
||||||
|
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
|
||||||
|
name = flag.String("name", "", "app name (for -buildmode=exe)")
|
||||||
|
version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode")
|
||||||
|
printCommands = flag.Bool("x", false, "print the commands")
|
||||||
|
keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
|
||||||
|
linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
|
||||||
|
extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker")
|
||||||
|
extraTags = flag.String("tags", "", "extra tags to the Go tool")
|
||||||
|
iconPath = flag.String("icon", "", "specify an icon for iOS and Android")
|
||||||
|
signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
|
||||||
|
signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.")
|
||||||
|
notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.")
|
||||||
|
notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.")
|
||||||
|
notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprint(os.Stderr, mainUsage)
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
if err := flagValidate(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
buildInfo, err := newBuildInfo(flag.Arg(0))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := build(buildInfo); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagValidate() error {
|
||||||
|
pkgPathArg := flag.Arg(0)
|
||||||
|
if pkgPathArg == "" {
|
||||||
|
return errors.New("specify a package")
|
||||||
|
}
|
||||||
|
if *target == "" {
|
||||||
|
return errors.New("please specify -target")
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "ios", "tvos", "android", "js", "windows", "macos":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid -target %s", *target)
|
||||||
|
}
|
||||||
|
switch *buildMode {
|
||||||
|
case "archive", "exe":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid -buildmode %s", *buildMode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func build(bi *buildInfo) error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gogio-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *keepWorkdir {
|
||||||
|
fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
|
||||||
|
} else {
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
switch *target {
|
||||||
|
case "js":
|
||||||
|
return buildJS(bi)
|
||||||
|
case "ios", "tvos":
|
||||||
|
return buildIOS(tmpDir, *target, bi)
|
||||||
|
case "android":
|
||||||
|
return buildAndroid(tmpDir, bi)
|
||||||
|
case "windows":
|
||||||
|
return buildWindows(tmpDir, bi)
|
||||||
|
case "macos":
|
||||||
|
return buildMac(tmpDir, bi)
|
||||||
|
default:
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
|
||||||
|
if *printCommands {
|
||||||
|
fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
|
||||||
|
}
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err, ok := err.(*exec.ExitError); ok {
|
||||||
|
return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmd(cmd *exec.Cmd) (string, error) {
|
||||||
|
out, err := runCmdRaw(cmd)
|
||||||
|
return string(bytes.TrimSpace(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(dst, src string) (err error) {
|
||||||
|
r, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
w, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := w.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type arch struct {
|
||||||
|
iosArch string
|
||||||
|
jniArch string
|
||||||
|
clangArch string
|
||||||
|
}
|
||||||
|
|
||||||
|
var allArchs = map[string]arch{
|
||||||
|
"arm": {
|
||||||
|
iosArch: "armv7",
|
||||||
|
jniArch: "armeabi-v7a",
|
||||||
|
clangArch: "armv7a-linux-androideabi",
|
||||||
|
},
|
||||||
|
"arm64": {
|
||||||
|
iosArch: "arm64",
|
||||||
|
jniArch: "arm64-v8a",
|
||||||
|
clangArch: "aarch64-linux-android",
|
||||||
|
},
|
||||||
|
"386": {
|
||||||
|
iosArch: "i386",
|
||||||
|
jniArch: "x86",
|
||||||
|
clangArch: "i686-linux-android",
|
||||||
|
},
|
||||||
|
"amd64": {
|
||||||
|
iosArch: "x86_64",
|
||||||
|
jniArch: "x86_64",
|
||||||
|
clangArch: "x86_64-linux-android",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type iconVariant struct {
|
||||||
|
path string
|
||||||
|
size int
|
||||||
|
fill bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildIcons(baseDir, icon string, variants []iconVariant) error {
|
||||||
|
f, err := os.Open(icon)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resizes errgroup.Group
|
||||||
|
for _, v := range variants {
|
||||||
|
v := v
|
||||||
|
resizes.Go(func() (err error) {
|
||||||
|
path := filepath.Join(baseDir, v.path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return png.Encode(f, resizeIcon(v, img))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resizes.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
|
||||||
|
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
|
||||||
|
op := draw.Src
|
||||||
|
if v.fill {
|
||||||
|
op = draw.Over
|
||||||
|
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
|
||||||
|
|
||||||
|
return scaled
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv("RUN_GOGIO") != "" {
|
||||||
|
// Allow the end-to-end tests to call the gogio tool without
|
||||||
|
// having to build it from scratch, nor having to refactor the
|
||||||
|
// main function to avoid using global variables.
|
||||||
|
main()
|
||||||
|
os.Exit(0) // main already exits, but just in case.
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
var AndroidPermissions = map[string][]string{
|
||||||
|
"network": {
|
||||||
|
"android.permission.INTERNET",
|
||||||
|
},
|
||||||
|
"networkstate": {
|
||||||
|
"android.permission.ACCESS_NETWORK_STATE",
|
||||||
|
},
|
||||||
|
"bluetooth": {
|
||||||
|
"android.permission.BLUETOOTH",
|
||||||
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
|
},
|
||||||
|
"wakelock": {
|
||||||
|
"android.permission.WAKE_LOCK",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var AndroidFeatures = map[string][]string{
|
||||||
|
"default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
|
||||||
|
"bluetooth": {
|
||||||
|
`name="android.hardware.bluetooth"`,
|
||||||
|
`name="android.hardware.bluetooth_le"`,
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
`name="android.hardware.camera"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
//go:build race
|
||||||
|
// +build race
|
||||||
|
|
||||||
|
package main_test
|
||||||
|
|
||||||
|
func init() { raceEnabled = true }
|
||||||