Compare commits
52 Commits
v0.0.1
...
44da1e6599
| Author | SHA1 | Date | |
|---|---|---|---|
| 44da1e6599 | |||
| b59cf8044b | |||
| 5838588fc5 | |||
| 0e9fd478e5 | |||
| 2f1cd7876c | |||
| ccaee9fa34 | |||
| c442a20d3e | |||
| cdf0c0c2c7 | |||
| 6ccff23804 | |||
| c3a9c0fddb | |||
| b593b1e6a7 | |||
| fe921b8790 | |||
| 7751b5472a | |||
| 07a071503a | |||
| b256a77d0c | |||
| 74d10535a1 | |||
| 16f603ccba | |||
| 9660369851 | |||
| 0a9201e0d1 | |||
| 74a2bbdc92 | |||
| 168927713c | |||
| 7a50138640 | |||
| d7741d14f5 | |||
| 5a98fe1a75 | |||
| 09e6425b1c | |||
| 4f9792d027 | |||
| 36c6687168 | |||
| 101a875837 | |||
| 81f1bcfca8 | |||
| b33f4905ab | |||
| edf0a9090d | |||
| 9b3f10f086 | |||
| cbfbe3be14 | |||
| f1f5d80ed8 | |||
| edac0f50a6 | |||
| 288cb34f1a | |||
| e88d1fd875 | |||
| a867ac4664 | |||
| 1aab5367a8 | |||
| 5d435f1f1f | |||
| 7868a77c8a | |||
| 43ef58936b | |||
| cb6fbd05a3 | |||
| 739d918c21 | |||
| 332ab58f58 | |||
| 8433d536f6 | |||
| 21b2e60df4 | |||
| 70f18e89bf | |||
| c361ec5ba3 | |||
| 0c6d707325 | |||
| 1c72a5009f | |||
| 9aeb98da58 |
@@ -107,6 +107,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
|
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
|
||||||
# Keep the CI matrix to targets this runner can build reproducibly.
|
# Keep the CI matrix to targets this runner can build reproducibly.
|
||||||
for target in \
|
for target in \
|
||||||
@@ -126,14 +127,19 @@ jobs:
|
|||||||
ext=".exe"
|
ext=".exe"
|
||||||
fi
|
fi
|
||||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" go build -o "${out}" .
|
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
make apk
|
signkey_path="$(mktemp)"
|
||||||
|
trap 'rm -f -- "$signkey_path"' EXIT
|
||||||
|
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
|
||||||
|
export APP_VERSION="$(git describe --tags --always --dirty)"
|
||||||
|
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
|
||||||
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
|
||||||
|
|
||||||
- name: Upload CI artifacts
|
- name: Upload CI artifacts
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
build/
|
build/
|
||||||
*.apk
|
*.apk
|
||||||
|
/keepassgo
|
||||||
|
android/keepassgo-android.jar
|
||||||
|
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||||
|
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||||
|
packaging/archlinux/keepassgo-git/pkg/
|
||||||
|
packaging/archlinux/keepassgo-git/src/
|
||||||
|
packaging/archlinux/keepassgo-git/keepassgo/
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
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,6 +95,20 @@ 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
|
||||||
|
|
||||||
@@ -104,6 +118,20 @@ 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
|
||||||
|
|
||||||
@@ -114,6 +142,14 @@ 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.
|
||||||
|
|
||||||
@@ -122,6 +158,13 @@ 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
|
||||||
|
|
||||||
@@ -144,6 +187,10 @@ 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.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Environment:
|
|||||||
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
|
||||||
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
|
||||||
- `APP_ID` overrides the Android application id.
|
- `APP_ID` overrides the Android application id.
|
||||||
|
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
|
||||||
- `APK_OUT` overrides the output path.
|
- `APK_OUT` overrides the output path.
|
||||||
- `APK_VERSION` overrides the packaged app version.
|
- `APK_VERSION` overrides the packaged app version.
|
||||||
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
|
||||||
@@ -28,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 ...
|
go tool gogio -target android ./cmd/keepassgo ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The Android build uses the branded icon asset at:
|
The Android build uses the branded icon asset at:
|
||||||
|
|
||||||
- `assets/keepassgo-icon.png`
|
- `internal/assets/keepassgo-icon.png`
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,27 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
|
|||||||
APP_ID ?= org.julianfamily.keepassgo
|
APP_ID ?= org.julianfamily.keepassgo
|
||||||
APK_OUT ?= build/keepassgo.apk
|
APK_OUT ?= build/keepassgo.apk
|
||||||
APK_VERSION ?= 0.1.0.1
|
APK_VERSION ?= 0.1.0.1
|
||||||
|
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||||
ANDROID_MIN_SDK ?= 28
|
ANDROID_MIN_SDK ?= 28
|
||||||
ANDROID_TARGET_SDK ?= 35
|
ANDROID_TARGET_SDK ?= 35
|
||||||
|
SIGNKEY ?=
|
||||||
|
SIGNPASS ?=
|
||||||
|
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||||
|
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||||
|
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||||
|
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||||
|
ARCH_REPO_DIR ?= $(CURDIR)
|
||||||
|
|
||||||
.PHONY: apk
|
GOGIO_SIGN_FLAGS :=
|
||||||
|
ifneq ($(strip $(SIGNKEY)),)
|
||||||
|
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
|
||||||
|
endif
|
||||||
|
ifneq ($(strip $(SIGNPASS)),)
|
||||||
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: apk archlinux-pkgbuild
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@@ -24,12 +41,14 @@ apk: android/keepassgo-android.jar
|
|||||||
go tool gogio -target android \
|
go tool gogio -target android \
|
||||||
-buildmode exe \
|
-buildmode exe \
|
||||||
-appid $(APP_ID) \
|
-appid $(APP_ID) \
|
||||||
|
-ldflags "$(GO_LDFLAGS)" \
|
||||||
|
$(GOGIO_SIGN_FLAGS) \
|
||||||
-o $(APK_OUT) \
|
-o $(APK_OUT) \
|
||||||
-version $(APK_VERSION) \
|
-version $(APK_VERSION) \
|
||||||
-minsdk $(ANDROID_MIN_SDK) \
|
-minsdk $(ANDROID_MIN_SDK) \
|
||||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||||
-icon assets/keepassgo-icon.png \
|
-icon internal/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; }
|
||||||
@@ -42,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
|||||||
-d "$$tmpdir" \
|
-d "$$tmpdir" \
|
||||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||||
|
|
||||||
|
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||||
|
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||||
|
@sed \
|
||||||
|
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||||
|
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||||
|
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||||
|
|||||||
@@ -38,7 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
|||||||
Desktop build:
|
Desktop build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./...
|
go build ./cmd/keepassgo
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
||||||
|
You can override the version shown in KeePassGO with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arch Linux Package
|
## Arch Linux Package
|
||||||
@@ -82,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
|||||||
Package:
|
Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go tool gogio -target android -icon assets/keepassgo-icon.png .
|
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
||||||
```
|
```
|
||||||
|
|
||||||
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,6 +11,36 @@ 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.
|
||||||
|
|||||||
@@ -22,3 +22,26 @@
|
|||||||
android:name="android.accessibilityservice"
|
android:name="android.accessibilityservice"
|
||||||
android:resource="@xml/keepassgo_accessibility_service" />
|
android:resource="@xml/keepassgo_accessibility_service" />
|
||||||
</service>
|
</service>
|
||||||
|
<provider
|
||||||
|
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||||
|
android:authorities="org.julianfamily.keepassgo.share"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true" />
|
||||||
|
<activity
|
||||||
|
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="application/octet-stream" />
|
||||||
|
<data android:mimeType="application/x-keepass2" />
|
||||||
|
<data android:mimeType="application/vnd.keepass" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||||
|
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public final class AndroidShare {
|
||||||
|
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
||||||
|
|
||||||
|
private AndroidShare() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void shareVault(Context context, String path, String title) throws IOException {
|
||||||
|
File source = new File(path);
|
||||||
|
if (!source.isFile()) {
|
||||||
|
throw new IOException("vault file not found: " + path);
|
||||||
|
}
|
||||||
|
File shared = copyToSharedExport(context, source);
|
||||||
|
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
||||||
|
|
||||||
|
Intent send = new Intent(Intent.ACTION_SEND);
|
||||||
|
send.setType("application/x-keepass2");
|
||||||
|
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||||
|
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
||||||
|
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
|
||||||
|
Intent chooser = Intent.createChooser(send, "Share vault");
|
||||||
|
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
context.startActivity(chooser);
|
||||||
|
}
|
||||||
|
|
||||||
|
static File sharedDirectory(Context context) {
|
||||||
|
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File copyToSharedExport(Context context, File source) throws IOException {
|
||||||
|
File dir = sharedDirectory(context);
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
File target = new File(dir, sanitizeFilename(source.getName()));
|
||||||
|
try (FileInputStream in = new FileInputStream(source);
|
||||||
|
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) >= 0) {
|
||||||
|
out.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeFilename(String name) {
|
||||||
|
String trimmed = name == null ? "" : name.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith(".kdbx")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed + ".kdbx";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeTitle(String title, String fallbackName) {
|
||||||
|
String trimmed = title == null ? "" : title.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
||||||
|
if (!fallback.isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public final class SharedVaultImportActivity extends Activity {
|
||||||
|
private static final String TAG = "KeePassGOImport";
|
||||||
|
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
handleIntent(getIntent());
|
||||||
|
launchMainActivity();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
handleIntent(intent);
|
||||||
|
launchMainActivity();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleIntent(Intent intent) {
|
||||||
|
Uri uri = resolveSharedUri(intent);
|
||||||
|
if (uri == null) {
|
||||||
|
Log.i(TAG, "no shared vault URI on intent");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
persistPendingImport(uri);
|
||||||
|
Log.i(TAG, "queued shared vault import from " + uri);
|
||||||
|
} catch (IOException | RuntimeException err) {
|
||||||
|
Log.e(TAG, "failed to queue shared vault import", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri resolveSharedUri(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (Intent.ACTION_SEND.equals(action)) {
|
||||||
|
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||||
|
}
|
||||||
|
if (Intent.ACTION_VIEW.equals(action)) {
|
||||||
|
return intent.getData();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistPendingImport(Uri uri) throws IOException {
|
||||||
|
File dir = new File(getFilesDir(), "keepassgo");
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||||
|
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IOException("failed to open shared vault stream");
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) >= 0) {
|
||||||
|
out.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||||
|
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||||
|
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveDisplayName(Uri uri) {
|
||||||
|
String displayName = queryDisplayName(uri);
|
||||||
|
if (!displayName.isEmpty()) {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
String lastSegment = uri.getLastPathSegment();
|
||||||
|
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||||
|
return lastSegment.trim();
|
||||||
|
}
|
||||||
|
return DEFAULT_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryDisplayName(Uri uri) {
|
||||||
|
Cursor cursor = null;
|
||||||
|
try {
|
||||||
|
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (index >= 0) {
|
||||||
|
String value = cursor.getString(index);
|
||||||
|
if (value != null) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException err) {
|
||||||
|
Log.w(TAG, "failed to query display name", err);
|
||||||
|
} finally {
|
||||||
|
if (cursor != null) {
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launchMainActivity() {
|
||||||
|
Intent launch = new Intent();
|
||||||
|
launch.setClassName(this, "org.gioui.GioActivity");
|
||||||
|
startActivity(launch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
|
||||||
|
public final class SharedVaultProvider extends ContentProvider {
|
||||||
|
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||||
|
File file = resolveSharedFile(uri);
|
||||||
|
String[] columns = projection;
|
||||||
|
if (columns == null || columns.length == 0) {
|
||||||
|
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||||
|
Object[] row = new Object[columns.length];
|
||||||
|
for (int i = 0; i < columns.length; i++) {
|
||||||
|
switch (columns[i]) {
|
||||||
|
case OpenableColumns.DISPLAY_NAME:
|
||||||
|
row[i] = file.getName();
|
||||||
|
break;
|
||||||
|
case OpenableColumns.SIZE:
|
||||||
|
row[i] = file.length();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
row[i] = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.addRow(row);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
return "application/x-keepass2";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("insert is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("delete is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("update is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||||
|
File file = resolveSharedFile(uri);
|
||||||
|
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri uriForFile(String name) {
|
||||||
|
return new Uri.Builder()
|
||||||
|
.scheme("content")
|
||||||
|
.authority(AUTHORITY)
|
||||||
|
.appendPath(name)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File resolveSharedFile(Uri uri) {
|
||||||
|
if (getContext() == null) {
|
||||||
|
throw new IllegalStateException("provider context is unavailable");
|
||||||
|
}
|
||||||
|
String name = sanitizeFilename(uri.getLastPathSegment());
|
||||||
|
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeFilename(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
String trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
return new File(trimmed).getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +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"
|
||||||
DefaultMinSDK = "28"
|
DefaultMinSDK = "28"
|
||||||
DefaultTargetSDK = "35"
|
DefaultTargetSDK = "35"
|
||||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -25,6 +26,7 @@ type Config struct {
|
|||||||
AppID string
|
AppID string
|
||||||
APKOut string
|
APKOut string
|
||||||
Version string
|
Version string
|
||||||
|
Ldflags string
|
||||||
MinSDK string
|
MinSDK string
|
||||||
TargetSDK string
|
TargetSDK string
|
||||||
IconPath string
|
IconPath string
|
||||||
@@ -38,6 +40,7 @@ func DefaultConfig() Config {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
IconPath: DefaultIconPath,
|
IconPath: DefaultIconPath,
|
||||||
@@ -49,12 +52,13 @@ func (c Config) GogioArgs() []string {
|
|||||||
"-target", "android",
|
"-target", "android",
|
||||||
"-buildmode", "exe",
|
"-buildmode", "exe",
|
||||||
"-appid", c.AppID,
|
"-appid", c.AppID,
|
||||||
|
"-ldflags", c.Ldflags,
|
||||||
"-o", c.APKOut,
|
"-o", c.APKOut,
|
||||||
"-version", c.Version,
|
"-version", c.Version,
|
||||||
"-minsdk", c.MinSDK,
|
"-minsdk", c.MinSDK,
|
||||||
"-targetsdk", c.TargetSDK,
|
"-targetsdk", c.TargetSDK,
|
||||||
"-icon", c.IconPath,
|
"-icon", c.IconPath,
|
||||||
".",
|
"./cmd/keepassgo",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
|||||||
"-target", "android",
|
"-target", "android",
|
||||||
"-buildmode", "exe",
|
"-buildmode", "exe",
|
||||||
"-appid", DefaultAppID,
|
"-appid", DefaultAppID,
|
||||||
|
"-ldflags", DefaultLdflags,
|
||||||
"-o", DefaultAPKOut,
|
"-o", DefaultAPKOut,
|
||||||
"-version", DefaultVersion,
|
"-version", DefaultVersion,
|
||||||
"-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) {
|
||||||
@@ -52,6 +53,7 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
IconPath: filepath.Join(root, "icon.png"),
|
IconPath: filepath.Join(root, "icon.png"),
|
||||||
@@ -77,6 +79,7 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
|
|||||||
AppID: DefaultAppID,
|
AppID: DefaultAppID,
|
||||||
APKOut: DefaultAPKOut,
|
APKOut: DefaultAPKOut,
|
||||||
Version: DefaultVersion,
|
Version: DefaultVersion,
|
||||||
|
Ldflags: DefaultLdflags,
|
||||||
MinSDK: DefaultMinSDK,
|
MinSDK: DefaultMinSDK,
|
||||||
TargetSDK: DefaultTargetSDK,
|
TargetSDK: DefaultTargetSDK,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "git.julianfamily.org/keepassgo/internal/appui"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
appui.Main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Local-First Remote Sync Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||||
|
|
||||||
|
- every vault is a local KDBX file first
|
||||||
|
- remote sync is an optional binding on top of that local file
|
||||||
|
- shared remote configuration lives in the vault
|
||||||
|
- user-specific remote credentials live in the vault
|
||||||
|
- app-local state stores only non-secret binding metadata
|
||||||
|
|
||||||
|
Android adds only one platform-specific capability on top of that model:
|
||||||
|
|
||||||
|
- share/import the initial local KDBX file between devices
|
||||||
|
|
||||||
|
## Product Rules
|
||||||
|
|
||||||
|
1. A remote-backed vault must always have a local cache KDBX file.
|
||||||
|
2. Opening a remote-backed vault should open the local KDBX first.
|
||||||
|
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||||
|
4. Remote credentials must not be stored in plaintext app-local state.
|
||||||
|
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||||
|
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||||
|
7. Sync must support both manual and automatic modes.
|
||||||
|
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||||
|
|
||||||
|
## Target Data Model
|
||||||
|
|
||||||
|
### In-Vault Shared Remote Profile
|
||||||
|
|
||||||
|
Store a reusable remote profile in the vault with fields such as:
|
||||||
|
|
||||||
|
- profile ID
|
||||||
|
- profile name
|
||||||
|
- backend type, initially WebDAV
|
||||||
|
- base URL
|
||||||
|
- remote object path
|
||||||
|
- optional notes or labels
|
||||||
|
- default sync policy, if shared defaults are desirable
|
||||||
|
|
||||||
|
### In-Vault User Credential Binding
|
||||||
|
|
||||||
|
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||||
|
|
||||||
|
- remote profile ID
|
||||||
|
- credential entry UUID, or another stable internal reference
|
||||||
|
- optional username field override if needed
|
||||||
|
|
||||||
|
The credential entry should contain the actual username/password or token.
|
||||||
|
|
||||||
|
### Local App State
|
||||||
|
|
||||||
|
Persist only non-secret binding state such as:
|
||||||
|
|
||||||
|
- local vault path
|
||||||
|
- selected remote profile ID
|
||||||
|
- selected credential entry reference
|
||||||
|
- sync policy override
|
||||||
|
- last sync metadata
|
||||||
|
- conflict or recovery markers
|
||||||
|
|
||||||
|
## Core Flows
|
||||||
|
|
||||||
|
### Create Or Configure Remote Sync
|
||||||
|
|
||||||
|
1. Open or create a local vault.
|
||||||
|
2. Create or edit a shared remote profile in that vault.
|
||||||
|
3. Create or select a credential entry in that vault.
|
||||||
|
4. Bind the local vault to the selected remote profile and credential reference.
|
||||||
|
5. Choose manual or automatic sync behavior.
|
||||||
|
|
||||||
|
### Reopen Existing Remote-Backed Vault
|
||||||
|
|
||||||
|
1. Open the local vault file from app state.
|
||||||
|
2. Resolve the selected remote profile from vault contents.
|
||||||
|
3. Resolve the credential entry from vault contents.
|
||||||
|
4. Offer or perform sync based on the binding policy.
|
||||||
|
|
||||||
|
### Bootstrap A New Android Device
|
||||||
|
|
||||||
|
1. Share the local KDBX file through Android Sharesheet.
|
||||||
|
2. Import and open that KDBX locally on the new device.
|
||||||
|
3. Select a remote profile stored in the vault.
|
||||||
|
4. Select or create that user’s credential entry in the vault.
|
||||||
|
5. Bind the local vault as the cache for the remote-backed setup.
|
||||||
|
|
||||||
|
## Migration Requirements
|
||||||
|
|
||||||
|
1. Migrate existing remote connections that save credentials in app state.
|
||||||
|
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||||
|
3. Replace saved plaintext credential state with a vault credential reference.
|
||||||
|
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||||
|
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Domain Model
|
||||||
|
|
||||||
|
- define remote profile structures independent of Gio UI
|
||||||
|
- define credential reference structures independent of Gio UI
|
||||||
|
- define sync binding state independent of Gio UI
|
||||||
|
- add behavior tests for local-first remote-backed vaults
|
||||||
|
|
||||||
|
### Phase 2: Vault Storage
|
||||||
|
|
||||||
|
- persist remote profiles in the vault
|
||||||
|
- persist credential references in the vault
|
||||||
|
- resolve credentials from normal vault entries
|
||||||
|
- add behavior tests for read/write and lookup semantics
|
||||||
|
|
||||||
|
### Phase 3: State And Open Flow
|
||||||
|
|
||||||
|
- shrink app state to non-secret metadata only
|
||||||
|
- update open flows to always prefer the local cache vault
|
||||||
|
- update reopen behavior on all platforms to use the same model
|
||||||
|
- add migration coverage for old remote state
|
||||||
|
|
||||||
|
### Phase 4: Sync Binding
|
||||||
|
|
||||||
|
- bind a local vault to a selected remote profile
|
||||||
|
- support manual sync
|
||||||
|
- support automatic sync on open/save
|
||||||
|
- define conflict and remote-failure handling for the local cache model
|
||||||
|
|
||||||
|
### Phase 5: Android Bootstrap
|
||||||
|
|
||||||
|
- add Android Sharesheet export of the current local KDBX
|
||||||
|
- add Android import flow for a shared KDBX
|
||||||
|
- keep the remote pivot flow consistent with desktop after local open
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||||
|
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||||
|
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||||
|
4. How should multiple remote profiles per vault be presented in the UI?
|
||||||
|
5. What should happen when the credential entry reference no longer resolves?
|
||||||
|
|
||||||
|
## Recommended First Slice
|
||||||
|
|
||||||
|
Implement the shared domain model and tests first:
|
||||||
|
|
||||||
|
- model a local vault plus optional remote binding
|
||||||
|
- define in-vault remote profile and credential reference semantics
|
||||||
|
- add tests proving app state no longer needs plaintext remote credentials
|
||||||
|
|
||||||
|
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||||
@@ -2,8 +2,6 @@ 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,6 +39,8 @@ 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=
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/internal/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/passwords"
|
"git.julianfamily.org/keepassgo/internal/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/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/internal/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/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/internal/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/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EventType string
|
type EventType string
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncModeManual SyncMode = "manual"
|
||||||
|
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteBinding struct {
|
||||||
|
LocalVaultPath string `json:"localVaultPath"`
|
||||||
|
RemoteProfileID string `json:"remoteProfileId"`
|
||||||
|
CredentialEntryID string `json:"credentialEntryId"`
|
||||||
|
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedRemoteBinding struct {
|
||||||
|
Profile vault.RemoteProfile
|
||||||
|
Credentials vault.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteBindingInput struct {
|
||||||
|
LocalVaultPath string
|
||||||
|
RemoteProfileID string
|
||||||
|
RemoteProfileName string
|
||||||
|
BaseURL string
|
||||||
|
RemotePath string
|
||||||
|
CredentialEntryID string
|
||||||
|
CredentialTitle string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
CredentialPath []string
|
||||||
|
SyncMode SyncMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||||
|
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||||
|
}
|
||||||
|
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||||
|
}
|
||||||
|
return ResolvedRemoteBinding{
|
||||||
|
Profile: profile,
|
||||||
|
Credentials: credentials,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
||||||
|
if model == nil {
|
||||||
|
return RemoteBinding{}, fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
||||||
|
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
||||||
|
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
||||||
|
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
||||||
|
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
||||||
|
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
||||||
|
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case input.LocalVaultPath == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
||||||
|
case input.RemoteProfileID == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
||||||
|
case input.BaseURL == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
||||||
|
case input.RemotePath == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
||||||
|
case input.CredentialEntryID == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
||||||
|
case input.Password == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RemoteProfileName == "" {
|
||||||
|
input.RemoteProfileName = input.RemoteProfileID
|
||||||
|
}
|
||||||
|
if input.CredentialTitle == "" {
|
||||||
|
input.CredentialTitle = "Remote Sign-In"
|
||||||
|
}
|
||||||
|
|
||||||
|
model.UpsertRemoteProfile(vault.RemoteProfile{
|
||||||
|
ID: input.RemoteProfileID,
|
||||||
|
Name: input.RemoteProfileName,
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: input.BaseURL,
|
||||||
|
Path: input.RemotePath,
|
||||||
|
})
|
||||||
|
model.UpsertEntry(vault.Entry{
|
||||||
|
ID: input.CredentialEntryID,
|
||||||
|
Title: input.CredentialTitle,
|
||||||
|
Username: input.Username,
|
||||||
|
Password: input.Password,
|
||||||
|
URL: input.BaseURL,
|
||||||
|
Path: append([]string(nil), input.CredentialPath...),
|
||||||
|
})
|
||||||
|
|
||||||
|
return RemoteBinding{
|
||||||
|
LocalVaultPath: input.LocalVaultPath,
|
||||||
|
RemoteProfileID: input.RemoteProfileID,
|
||||||
|
CredentialEntryID: input.CredentialEntryID,
|
||||||
|
SyncMode: normalizeSyncMode(input.SyncMode),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
||||||
|
if model == nil {
|
||||||
|
return fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
||||||
|
return fmt.Errorf("remote profile id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
||||||
|
return fmt.Errorf("credential entry id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
||||||
|
model.RemoveEntryByID(binding.CredentialEntryID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSyncMode(mode SyncMode) SyncMode {
|
||||||
|
switch mode {
|
||||||
|
case SyncModeAutomaticOnOpenSave:
|
||||||
|
return SyncModeAutomaticOnOpenSave
|
||||||
|
default:
|
||||||
|
return SyncModeManual
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "linuscaldwell-webdav",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
binding := RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "linuscaldwell-webdav",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := binding.Resolve(model)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
||||||
|
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err = (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content, err := json.Marshal(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(content)
|
||||||
|
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
||||||
|
if strings.Contains(text, disallowed) {
|
||||||
|
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var model vault.Model
|
||||||
|
|
||||||
|
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
RemoteProfileName: "Bellagio Vault",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(model.RemoteProfiles) != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
||||||
|
}
|
||||||
|
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := model.EntryByID("remote-creds-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
|
}
|
||||||
|
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||||
|
}
|
||||||
|
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
||||||
|
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
||||||
|
}
|
||||||
|
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||||
|
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
input RemoteBindingInput
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing_local_vault_path",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_remote_base_url",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_credential_entry_id",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var model vault.Model
|
||||||
|
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(model.RemoteProfiles); got != 0 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Section string
|
type Section string
|
||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
SectionRecycleBin Section = "recycle-bin"
|
SectionRecycleBin Section = "recycle-bin"
|
||||||
SectionAPITokens Section = "api-tokens"
|
SectionAPITokens Section = "api-tokens"
|
||||||
SectionAPIAudit Section = "api-audit"
|
SectionAPIAudit Section = "api-audit"
|
||||||
|
SectionAbout Section = "about"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CurrentSession interface {
|
type CurrentSession interface {
|
||||||
@@ -132,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
|||||||
return apitokens.Entries(model)
|
return apitokens.Entries(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles := slices.Clone(model.RemoteProfiles)
|
||||||
|
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||||
|
switch {
|
||||||
|
case a.Name < b.Name:
|
||||||
|
return -1
|
||||||
|
case a.Name > b.Name:
|
||||||
|
return 1
|
||||||
|
case a.ID < b.ID:
|
||||||
|
return -1
|
||||||
|
case a.ID > b.ID:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := slices.Clone(model.Entries)
|
||||||
|
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
case a.ID < b.ID:
|
||||||
|
return -1
|
||||||
|
case a.ID > b.ID:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -376,7 +423,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
|||||||
return slices.Clone(model.Templates)
|
return slices.Clone(model.Templates)
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
return slices.Clone(model.RecycleBin)
|
return slices.Clone(model.RecycleBin)
|
||||||
case SectionAPITokens, SectionAPIAudit:
|
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return slices.Clone(model.Entries)
|
return slices.Clone(model.Entries)
|
||||||
@@ -833,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := binding.Resolve(model)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return RemoteBinding{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
binding, err := ConfigureRemoteBinding(&model, input)
|
||||||
|
if err != nil {
|
||||||
|
return RemoteBinding{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) CreateGroup(name string) error {
|
func (s *State) CreateGroup(name string) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/internal/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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "archive-webdav",
|
||||||
|
Name: "Archive Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/archive.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.RemoteProfiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
|
||||||
|
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.RemoteCredentialEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteCredentialEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
|
||||||
|
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -173,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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,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 Home Assistant search match", got)
|
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -350,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 HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -419,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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -432,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{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,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: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
@@ -619,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 != "token-1" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "vault-console",
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
||||||
|
}
|
||||||
|
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
|
||||||
|
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
|
||||||
|
}
|
||||||
|
if len(state.CurrentPath) != 0 {
|
||||||
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after bound remote open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "missing-profile",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
|
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
RemoteProfileName: "Bellagio Vault",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||||
|
}
|
||||||
|
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
|
||||||
|
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
|
||||||
|
}
|
||||||
|
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||||
|
}
|
||||||
|
credentials, err := sess.model.EntryByID("remote-creds-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
|
}
|
||||||
|
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{Session: stubSession{model: vault.Model{}}}
|
||||||
|
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/bellagio/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||||
|
RemoteProfileID: "bellagio-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
|
||||||
|
}
|
||||||
|
if got := len(sess.model.RemoteProfiles); got != 0 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
|||||||
SelectedEntryID: "vault-console",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||||
|
|
||||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
t.Fatalf("CurrentPath = %v, want [Root Security Office]", 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)
|
||||||
@@ -1185,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{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,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", "Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||||
@@ -1282,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{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||||
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
|||||||
SelectedEntryID: "bellagio",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
||||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||||
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)
|
||||||
@@ -1512,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", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1554,14 +1798,16 @@ func (s *saveableStubSession) Save() error {
|
|||||||
|
|
||||||
type lifecycleStubSession struct {
|
type lifecycleStubSession struct {
|
||||||
createCalls int
|
createCalls int
|
||||||
|
model vault.Model
|
||||||
openPath string
|
openPath string
|
||||||
saveAsPath string
|
saveAsPath string
|
||||||
|
remoteClient webdav.Client
|
||||||
remotePath string
|
remotePath string
|
||||||
changedKey vault.MasterKey
|
changedKey vault.MasterKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||||
return vault.Model{}, nil
|
return s.model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||||
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
s.remoteClient = client
|
||||||
s.remotePath = path
|
s.remotePath = path
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
package main
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,91 +10,12 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiOperations() []apitokens.Operation {
|
type apiAuditQuickFilter = apiui.AuditQuickFilter
|
||||||
return []apitokens.Operation{
|
|
||||||
apitokens.OperationListEntries,
|
|
||||||
apitokens.OperationListGroups,
|
|
||||||
apitokens.OperationReadEntry,
|
|
||||||
apitokens.OperationCopyPassword,
|
|
||||||
apitokens.OperationCopyUsername,
|
|
||||||
apitokens.OperationCopyURL,
|
|
||||||
apitokens.OperationMutateEntry,
|
|
||||||
apitokens.OperationMutateGroup,
|
|
||||||
apitokens.OperationManageVault,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiAuditQuickFilter struct {
|
|
||||||
Label string
|
|
||||||
Query string
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
|
|
||||||
switch eventType {
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
return "Requested"
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
return "Allowed"
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
return "Denied"
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
return "Canceled"
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
return "Timed Out"
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
return "Auth Rejected"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditOperationLabel(operation apitokens.Operation) string {
|
|
||||||
if strings.TrimSpace(string(operation)) == "" {
|
|
||||||
return "Other"
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(string(operation), "_", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactAuditFilterLabel(label string) string {
|
|
||||||
label = strings.TrimSpace(label)
|
|
||||||
if len(label) <= 22 {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return label[:19] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditEventSearchTerms(event apiaudit.Event) string {
|
|
||||||
parts := []string{
|
|
||||||
string(event.Type),
|
|
||||||
apiAuditDecisionLabel(event.Type),
|
|
||||||
event.TokenName,
|
|
||||||
event.ClientName,
|
|
||||||
string(event.Operation),
|
|
||||||
apiAuditOperationLabel(event.Operation),
|
|
||||||
strings.Join(event.Resource.Path, " / "),
|
|
||||||
event.Resource.EntryID,
|
|
||||||
event.Message,
|
|
||||||
}
|
|
||||||
switch event.Type {
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
parts = append(parts, "allow approved")
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
parts = append(parts, "deny denied")
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
parts = append(parts, "prompt requested")
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
parts = append(parts, "cancel canceled")
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
parts = append(parts, "timeout timed out")
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
parts = append(parts, "rejected unauthorized")
|
|
||||||
}
|
|
||||||
return strings.ToLower(strings.Join(parts, " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
||||||
if len(filters) == 0 {
|
if len(filters) == 0 {
|
||||||
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
|||||||
}
|
}
|
||||||
if _, ok := decisionSeen[event.Type]; !ok {
|
if _, ok := decisionSeen[event.Type]; !ok {
|
||||||
decisionSeen[event.Type] = struct{}{}
|
decisionSeen[event.Type] = struct{}{}
|
||||||
label := apiAuditDecisionLabel(event.Type)
|
label := apiui.AuditDecisionLabel(event.Type)
|
||||||
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(event.Operation)) == "" {
|
if strings.TrimSpace(string(event.Operation)) == "" {
|
||||||
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
operationSeen[event.Operation] = struct{}{}
|
operationSeen[event.Operation] = struct{}{}
|
||||||
label := apiAuditOperationLabel(event.Operation)
|
label := apiui.AuditOperationLabel(event.Operation)
|
||||||
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
|
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +242,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) {
|
|||||||
|
|
||||||
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
||||||
value := apitokens.Operation(strings.TrimSpace(text))
|
value := apitokens.Operation(strings.TrimSpace(text))
|
||||||
for _, operation := range apiOperations() {
|
for _, operation := range apiui.Operations() {
|
||||||
if operation == value {
|
if operation == value {
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -450,7 +371,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
|||||||
}
|
}
|
||||||
filtered := make([]apiaudit.Event, 0, len(events))
|
filtered := make([]apiaudit.Event, 0, len(events))
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
haystack := apiAuditEventSearchTerms(event)
|
haystack := apiui.AuditEventSearchTerms(event)
|
||||||
if strings.Contains(haystack, query) {
|
if strings.Contains(haystack, query) {
|
||||||
filtered = append(filtered, event)
|
filtered = append(filtered, event)
|
||||||
}
|
}
|
||||||
@@ -785,7 +706,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
|||||||
click := &buttons[i]
|
click := &buttons[i]
|
||||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||||
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
|
||||||
@@ -799,7 +720,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
|||||||
click := &buttons[i]
|
click := &buttons[i]
|
||||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||||
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
|
||||||
@@ -1051,7 +972,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
|
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.apiPolicyGroupScopeW.Value {
|
if u.apiPolicyGroupScopeW.Value {
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
package main
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) attachmentInput() (string, []byte, error) {
|
func (u *ui) attachmentInput() (string, []byte, error) {
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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() || u.shouldShowDesktopWorkingHeader() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
spacing := gtx.Dp(unit.Dp(8))
|
||||||
|
metrics := headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
||||||
|
if !u.usesCompactViewport() {
|
||||||
|
metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
||||||
|
}
|
||||||
|
actionCluster := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
metrics.SyncDims, metrics.SyncPrimaryDims, metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
||||||
|
return 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")
|
||||||
|
metrics.LockDims = btn.Layout(gtx)
|
||||||
|
return metrics.LockDims
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||||
|
return metrics.MainDims
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowOps := op.Record(gtx.Ops)
|
||||||
|
metrics.RowDims = actionCluster(gtx)
|
||||||
|
rowCall := rowOps.Stop()
|
||||||
|
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
||||||
|
|
||||||
|
surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
||||||
|
|
||||||
|
rowDims := layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
rowCall.Add(gtx.Ops)
|
||||||
|
return metrics.RowDims
|
||||||
|
})
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), metrics.Bounds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
if u.syncMenuOpen {
|
||||||
|
u.phoneSyncMenuVisible = true
|
||||||
|
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
|
||||||
|
}
|
||||||
|
if u.mainMenuOpen {
|
||||||
|
u.phoneMainMenuVisible = true
|
||||||
|
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.syncMenuOpen {
|
||||||
|
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
|
||||||
|
}
|
||||||
|
if u.mainMenuOpen {
|
||||||
|
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowDims
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min = gtx.Constraints.Max
|
||||||
|
contentInsetPx := gtx.Dp(unit.Dp(16))
|
||||||
|
surface := headerlayout.DropdownSurface{
|
||||||
|
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
||||||
|
LeftInset: contentInsetPx,
|
||||||
|
TopInset: contentInsetPx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.syncMenuVisibleOnPhone() {
|
||||||
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
||||||
|
}
|
||||||
|
if u.mainMenuVisibleOnPhone() {
|
||||||
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
||||||
|
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)
|
||||||
|
stack := op.Offset(menuOrigin).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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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) 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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return card(gtx, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package header
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"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) layout.Dimensions {
|
||||||
|
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
||||||
|
return 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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
|
}, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"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) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||||
|
group, _, _ := u.syncButtonGroupWithMetrics(gtx)
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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)
|
||||||
|
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
}
|
||||||
|
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,32 +1,46 @@
|
|||||||
package main
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
"gioui.org/layout"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
type focusID string
|
type focusID string
|
||||||
|
|
||||||
type detailField string
|
type detailField = editormodel.Field
|
||||||
|
|
||||||
const (
|
const (
|
||||||
focusSearch focusID = "search"
|
focusSearch focusID = "search"
|
||||||
|
|
||||||
detailFieldID detailField = "id"
|
detailFieldID = editormodel.FieldID
|
||||||
detailFieldTitle detailField = "title"
|
detailFieldTitle = editormodel.FieldTitle
|
||||||
detailFieldUsername detailField = "username"
|
detailFieldUsername = editormodel.FieldUsername
|
||||||
detailFieldPassword detailField = "password"
|
detailFieldPassword = editormodel.FieldPassword
|
||||||
detailFieldURL detailField = "url"
|
detailFieldURL = editormodel.FieldURL
|
||||||
detailFieldPath detailField = "path"
|
detailFieldPath = editormodel.FieldPath
|
||||||
detailFieldTags detailField = "tags"
|
detailFieldTags = editormodel.FieldTags
|
||||||
detailFieldPasswordProfile detailField = "password-profile"
|
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
||||||
detailFieldNotes detailField = "notes"
|
detailFieldNotes = editormodel.FieldNotes
|
||||||
detailFieldFields detailField = "fields"
|
detailFieldFields = editormodel.FieldFields
|
||||||
detailFieldHistoryIndex detailField = "history-index"
|
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
shortcutSearch = "search"
|
||||||
|
shortcutSave = "save"
|
||||||
|
shortcutLock = "lock"
|
||||||
|
shortcutNewEntry = "new-entry"
|
||||||
|
shortcutCopyUser = "copy-user"
|
||||||
|
shortcutCopyPassword = "copy-password"
|
||||||
|
shortcutCopyURL = "copy-url"
|
||||||
)
|
)
|
||||||
|
|
||||||
func breadcrumbFocusID(index int) focusID {
|
func breadcrumbFocusID(index int) focusID {
|
||||||
@@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID {
|
|||||||
return focusID("detail:" + string(field))
|
return focusID("detail:" + string(field))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) processShortcuts(gtx layout.Context) {
|
||||||
|
event.Op(gtx.Ops, u)
|
||||||
|
for {
|
||||||
|
ev, ok := gtx.Event(
|
||||||
|
key.Filter{Name: "F", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "S", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "L", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "N", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "U", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "P", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "O", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
||||||
|
key.Filter{Name: key.NameLeftArrow},
|
||||||
|
key.Filter{Name: key.NameRightArrow},
|
||||||
|
key.Filter{Name: key.NameUpArrow},
|
||||||
|
key.Filter{Name: key.NameDownArrow},
|
||||||
|
key.Filter{Name: key.NameReturn},
|
||||||
|
key.Filter{Name: key.NameBack},
|
||||||
|
key.Filter{Name: key.NameEscape},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
ke, ok := ev.(key.Event)
|
||||||
|
if !ok || ke.State != key.Press {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||||
|
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
||||||
|
_ = u.handlePhoneBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) performShortcut(name string) error {
|
||||||
|
switch name {
|
||||||
|
case shortcutSearch:
|
||||||
|
u.keyboardFocus = focusSearch
|
||||||
|
return nil
|
||||||
|
case shortcutSave:
|
||||||
|
return u.saveAction()
|
||||||
|
case shortcutLock:
|
||||||
|
return u.lockAction()
|
||||||
|
case shortcutNewEntry:
|
||||||
|
u.state.BeginNewEntry()
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||||
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||||
|
return nil
|
||||||
|
case shortcutCopyUser:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
||||||
|
case shortcutCopyPassword:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
||||||
|
case shortcutCopyURL:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetURL)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
||||||
if u.handleShortcutKey(name, modifiers) {
|
if u.handleShortcutKey(name, modifiers) {
|
||||||
return true
|
return true
|
||||||
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func detailFocusOrder() []detailField {
|
func detailFocusOrder() []detailField {
|
||||||
return []detailField{
|
return editormodel.FocusOrder()
|
||||||
detailFieldID,
|
|
||||||
detailFieldTitle,
|
|
||||||
detailFieldUsername,
|
|
||||||
detailFieldPassword,
|
|
||||||
detailFieldURL,
|
|
||||||
detailFieldPath,
|
|
||||||
detailFieldTags,
|
|
||||||
detailFieldPasswordProfile,
|
|
||||||
detailFieldNotes,
|
|
||||||
detailFieldFields,
|
|
||||||
detailFieldHistoryIndex,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalFocusID(id focusID) focusID {
|
func canonicalFocusID(id focusID) focusID {
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
type OpenIntent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpenIntentNone OpenIntent = ""
|
||||||
|
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
||||||
|
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
||||||
|
)
|
||||||
@@ -0,0 +1,778 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
package main
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,13 +15,29 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||||
|
panel := card
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
panel = compactCard
|
||||||
|
}
|
||||||
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
rows := []layout.Widget{
|
||||||
|
u.lifecycleBranding,
|
||||||
|
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||||
|
u.lifecycleControls,
|
||||||
|
}
|
||||||
|
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
return rows[i](gtx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
showLocalChooser := u.showLocalVaultChooser()
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
|
||||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -31,154 +47,40 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), "UNLOCK")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.")
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if busy {
|
if busy {
|
||||||
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
|
return labeledEditorHelp(u.theme, "Key File", keyFileHelp(), &u.keyFilePath, false)(gtx)
|
||||||
}
|
}
|
||||||
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
|
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote")
|
|
||||||
}
|
|
||||||
return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.lifecycleMode == "remote" {
|
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if showRemoteChooser && !busy {
|
|
||||||
return u.recentRemoteList(gtx)
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
|
||||||
box.Color = accentColor
|
|
||||||
return box.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !showLocalChooser {
|
if !showLocalChooser {
|
||||||
@@ -200,6 +102,9 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return u.recentVaultList(gtx)
|
return u.recentVaultList(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser)
|
||||||
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !showLocalChooser {
|
if !showLocalChooser {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
@@ -221,6 +126,24 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
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 tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||||
switch {
|
switch {
|
||||||
case busy:
|
case busy:
|
||||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||||
@@ -229,49 +152,42 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
default:
|
default:
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||||
|
if u.shouldPrioritizeLifecyclePrimaryActions() {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "UNLOCK")
|
if busy {
|
||||||
lbl.Color = mutedColor
|
return layout.Dimensions{}
|
||||||
return lbl.Layout(gtx)
|
}
|
||||||
}),
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.")
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(u.lifecycleAdvancedCard),
|
||||||
if busy {
|
)
|
||||||
return labeledEditorHelp(u.theme, "Key File", keyFileHelp(), &u.keyFilePath, false)(gtx)
|
}
|
||||||
}
|
|
||||||
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
|
||||||
}),
|
if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" {
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.lifecycleAdvancedDisclosure(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || u.lifecycleAdvancedHidden {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -293,43 +209,29 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}),
|
}
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
label := u.remoteOpenButtonLabel()
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveTonedButton(gtx, u.theme, label)
|
|
||||||
}
|
|
||||||
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.selectedRemoteConnectionCard(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
label := "Open Vault"
|
label := "Open Vault"
|
||||||
if busy {
|
if busy {
|
||||||
label = "Opening Vault..."
|
return passiveTonedButton(gtx, u.theme, "Opening Vault...")
|
||||||
}
|
|
||||||
if busy {
|
|
||||||
return passiveTonedButton(gtx, u.theme, label)
|
|
||||||
}
|
}
|
||||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||||
@@ -343,74 +245,60 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || selectedLocalPath == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || selectedLocalPath == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||||
|
if busy || selectedLocalPath == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
|
||||||
|
return u.usesCompactViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardHeading() string {
|
||||||
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
|
return "CACHED VAULT"
|
||||||
|
}
|
||||||
|
return "SELECTED CONNECTION"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardPrimaryText() string {
|
||||||
|
record := u.currentRemoteRecord()
|
||||||
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if label := friendlyRecentVaultLabel(path); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return friendlyRecentRemoteLabel(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardDetailLines() []string {
|
||||||
record := u.currentRemoteRecord()
|
record := u.currentRemoteRecord()
|
||||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
lines := make([]string, 0, 3)
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
lines = append(lines, dir)
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
|
||||||
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(14), friendlyRecentRemoteLabel(record))
|
|
||||||
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(11), "Path: "+strings.TrimSpace(record.Path))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(lastGroup) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
||||||
lbl.Color = mutedColor
|
} else {
|
||||||
return lbl.Layout(gtx)
|
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
||||||
}),
|
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
}
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
if len(lastGroup) > 0 {
|
||||||
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||||
}),
|
}
|
||||||
)
|
return lines
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
||||||
@@ -446,6 +334,11 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
|||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
||||||
@@ -455,6 +348,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string {
|
||||||
|
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
|
||||||
|
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
|
||||||
|
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
return summary + " · Syncs automatically on open and save."
|
||||||
|
}
|
||||||
|
return summary + " · Sync manually when you choose Use Remote Sync."
|
||||||
|
}
|
||||||
|
return "Open this vault to set up a WebDAV sync target for it."
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
||||||
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
||||||
}
|
}
|
||||||
@@ -570,76 +474,6 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(u.recentRemotes) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
|
||||||
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
maxY := gtx.Dp(unit.Dp(180))
|
|
||||||
if gtx.Constraints.Max.Y > maxY {
|
|
||||||
gtx.Constraints.Max.Y = maxY
|
|
||||||
}
|
|
||||||
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
|
|
||||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
|
||||||
}
|
|
||||||
return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
|
|
||||||
record := u.recentRemotes[i]
|
|
||||||
label := friendlyRecentRemoteLabel(record)
|
|
||||||
selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path
|
|
||||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.recentRemoteClicks[i].Layout(gtx, 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(14), label)
|
|
||||||
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(11), "Path: "+strings.TrimSpace(record.Path))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(record.LastGroup) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / "))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions {
|
func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions {
|
||||||
if !selected {
|
if !selected {
|
||||||
return compactCard(gtx, w)
|
return compactCard(gtx, w)
|
||||||
@@ -750,21 +584,6 @@ func normalizedRemoteHost(baseURL string) string {
|
|||||||
return strings.TrimSuffix(host, "/")
|
return strings.TrimSuffix(host, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
|
|
||||||
username := strings.TrimSpace(record.Username)
|
|
||||||
hasPassword := record.Password != ""
|
|
||||||
switch {
|
|
||||||
case username != "" && hasPassword:
|
|
||||||
return "saved username and password"
|
|
||||||
case username != "":
|
|
||||||
return "saved username"
|
|
||||||
case hasPassword:
|
|
||||||
return "saved password"
|
|
||||||
default:
|
|
||||||
return "location only"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||||
items := u.selectedAttachmentItems()
|
items := u.selectedAttachmentItems()
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -1050,7 +869,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
label := "Group Tools"
|
label := "Group Tools"
|
||||||
size := unit.Sp(12)
|
size := unit.Sp(12)
|
||||||
if u.mode == "phone" {
|
if u.usesCompactViewport() {
|
||||||
size = unit.Sp(11)
|
size = unit.Sp(11)
|
||||||
}
|
}
|
||||||
lbl := material.Label(u.theme, size, label)
|
lbl := material.Label(u.theme, size, label)
|
||||||
@@ -1060,7 +879,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if u.mode == "phone" {
|
if u.usesCompactViewport() {
|
||||||
return content(gtx)
|
return content(gtx)
|
||||||
}
|
}
|
||||||
return compactCard(gtx, content)
|
return compactCard(gtx, content)
|
||||||
@@ -1125,7 +944,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.mode == "phone" {
|
if u.usesCompactViewport() {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
||||||
@@ -1184,7 +1003,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
|
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.mode == "phone" {
|
if u.usesCompactViewport() {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
|
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
|
||||||
@@ -1252,13 +1071,17 @@ func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Ed
|
|||||||
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
|
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func localVaultPathHelp() string {
|
func localVaultPathHelpForRuntime(goos string) string {
|
||||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) {
|
||||||
return "Choose the existing .kdbx file to open."
|
return "Choose the existing .kdbx file to open."
|
||||||
}
|
}
|
||||||
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
|
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func localVaultPathHelp() string {
|
||||||
|
return localVaultPathHelpForRuntime(runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
func keyFileHelp() string {
|
func keyFileHelp() string {
|
||||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||||
return "Optional path to a KeePass-compatible key file."
|
return "Optional path to a KeePass-compatible key file."
|
||||||
@@ -1267,7 +1090,7 @@ func keyFileHelp() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
|
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
|
||||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) {
|
||||||
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
|
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
|
||||||
}
|
}
|
||||||
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
|
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
type EntriesSectionState struct {
|
||||||
|
Path []string
|
||||||
|
SearchQuery string
|
||||||
|
SelectedEntryID string
|
||||||
|
Editing bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -Werror
|
||||||
|
#cgo LDFLAGS: -landroid
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
||||||
|
return (*env)->GetObjectClass(env, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||||
|
return (*env)->GetMethodID(env, clazz, name, sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||||
|
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
||||||
|
return (*env)->CallObjectMethodA(env, obj, method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
||||||
|
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jvalue jni_ValueObject(jobject obj) {
|
||||||
|
jvalue value;
|
||||||
|
value.l = obj;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
||||||
|
return (*env)->ExceptionOccurred(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void jni_ExceptionClear(JNIEnv *env) {
|
||||||
|
(*env)->ExceptionClear(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
||||||
|
return (*env)->NewString(env, unicodeChars, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int jni_IsNull(jobject obj) {
|
||||||
|
return obj == NULL;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
_ "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type androidVaultSharer struct{}
|
||||||
|
|
||||||
|
//go:linkname gioJavaVM gioui.org/app.javaVM
|
||||||
|
func gioJavaVM() *C.JavaVM
|
||||||
|
|
||||||
|
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||||
|
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||||
|
|
||||||
|
func NewVaultSharer(goos string) VaultSharer {
|
||||||
|
return androidVaultSharer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (androidVaultSharer) ShareVault(path, title string) error {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return fmt.Errorf("vault path is required")
|
||||||
|
}
|
||||||
|
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
||||||
|
if C.jni_IsNull(ctx) != 0 {
|
||||||
|
return fmt.Errorf("android app context is not available")
|
||||||
|
}
|
||||||
|
var callErr error
|
||||||
|
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
||||||
|
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
||||||
|
if err != nil {
|
||||||
|
callErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
methodName := cString("shareVault")
|
||||||
|
defer C.free(unsafe.Pointer(methodName))
|
||||||
|
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
||||||
|
defer C.free(unsafe.Pointer(methodSig))
|
||||||
|
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
||||||
|
if method == nil {
|
||||||
|
callErr = androidJNIError(env, "resolve shareVault method")
|
||||||
|
if callErr == nil {
|
||||||
|
callErr = fmt.Errorf("resolve shareVault method")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jPath := androidJavaString(env, path)
|
||||||
|
jTitle := androidJavaString(env, title)
|
||||||
|
args := [3]C.jvalue{}
|
||||||
|
args[0] = C.jni_ValueObject(ctx)
|
||||||
|
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
||||||
|
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
||||||
|
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
||||||
|
callErr = androidJNIError(env, "share vault")
|
||||||
|
})
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
||||||
|
var zeroClass C.jclass
|
||||||
|
contextClass := C.jni_GetObjectClass(env, ctx)
|
||||||
|
getClassLoaderName := cString("getClassLoader")
|
||||||
|
defer C.free(unsafe.Pointer(getClassLoaderName))
|
||||||
|
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
||||||
|
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
||||||
|
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
||||||
|
if getClassLoader == nil {
|
||||||
|
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
||||||
|
}
|
||||||
|
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
||||||
|
if err := androidJNIError(env, "load class loader"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
if C.jni_IsNull(classLoader) != 0 {
|
||||||
|
return zeroClass, fmt.Errorf("android class loader is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
||||||
|
loadClassName := cString("loadClass")
|
||||||
|
defer C.free(unsafe.Pointer(loadClassName))
|
||||||
|
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
||||||
|
defer C.free(unsafe.Pointer(loadClassSig))
|
||||||
|
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
||||||
|
if loadClass == nil {
|
||||||
|
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
return zeroClass, fmt.Errorf("resolve loadClass")
|
||||||
|
}
|
||||||
|
|
||||||
|
jClassName := androidJavaString(env, name)
|
||||||
|
args := [1]C.jvalue{}
|
||||||
|
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
||||||
|
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
||||||
|
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
if C.jni_IsNull(loaded) != 0 {
|
||||||
|
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
||||||
|
}
|
||||||
|
return C.jclass(loaded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidJNIError(env *C.JNIEnv, action string) error {
|
||||||
|
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
||||||
|
C.jni_ExceptionClear(env)
|
||||||
|
return fmt.Errorf("%s: Java exception", action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
||||||
|
chars := utf16.Encode([]rune(s))
|
||||||
|
if len(chars) == 0 {
|
||||||
|
return C.jni_NewString(env, nil, 0)
|
||||||
|
}
|
||||||
|
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cString(value string) *C.char {
|
||||||
|
return C.CString(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//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,4 +1,4 @@
|
|||||||
package main
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,23 +8,27 @@ import (
|
|||||||
gioclipboard "gioui.org/io/clipboard"
|
gioclipboard "gioui.org/io/clipboard"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
|
||||||
appclipboard "git.julianfamily.org/keepassgo/clipboard"
|
appclipboard "git.julianfamily.org/keepassgo/internal/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 newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
func NewClipboardWriter(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 main
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -8,17 +8,17 @@ import (
|
|||||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
writer := newPlatformClipboardWriter("android", nil)
|
writer := NewClipboardWriter("android", nil)
|
||||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||||
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||||
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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,7 +1,9 @@
|
|||||||
package main
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,32 +11,33 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
displayDensityDense = "dense"
|
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||||
displayDensityComfortable = "comfortable"
|
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||||
|
|
||||||
contrastStandard = "standard"
|
contrastStandard = settingsmodel.ContrastStandard
|
||||||
contrastHigh = "high"
|
contrastHigh = settingsmodel.ContrastHigh
|
||||||
|
|
||||||
keyboardFocusStandard = "standard"
|
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||||
keyboardFocusProminent = "prominent"
|
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||||
)
|
)
|
||||||
|
|
||||||
type accessibilityPreferences struct {
|
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||||
DisplayDensity string
|
|
||||||
Contrast string
|
|
||||||
ReducedMotion bool
|
|
||||||
KeyboardFocus string
|
|
||||||
}
|
|
||||||
|
|
||||||
type settingsFile struct {
|
type settingsFile struct {
|
||||||
Sync syncSettings `json:"sync,omitempty"`
|
Sync syncSettings `json:"sync,omitempty"`
|
||||||
|
Debug debugSettings `json:"debug,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type syncSettings struct {
|
type syncSettings struct {
|
||||||
@@ -42,6 +45,10 @@ type syncSettings struct {
|
|||||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type debugSettings struct {
|
||||||
|
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type syncSettingsDraft struct {
|
type syncSettingsDraft struct {
|
||||||
SourceDefault syncSourceMode
|
SourceDefault syncSourceMode
|
||||||
DirectionDefault syncDirection
|
DirectionDefault syncDirection
|
||||||
@@ -50,6 +57,7 @@ type syncSettingsDraft struct {
|
|||||||
type settingsDraft struct {
|
type settingsDraft struct {
|
||||||
Accessibility accessibilityPreferences
|
Accessibility accessibilityPreferences
|
||||||
Sync syncSettingsDraft
|
Sync syncSettingsDraft
|
||||||
|
Debug debugSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
type legacySyncPreferences struct {
|
type legacySyncPreferences struct {
|
||||||
@@ -63,37 +71,23 @@ type choiceSpec struct {
|
|||||||
Active bool
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type focusAppearance struct {
|
||||||
|
BorderColor color.NRGBA
|
||||||
|
OutlineColor color.NRGBA
|
||||||
|
OutlineWidth int
|
||||||
|
MinHeight int
|
||||||
|
}
|
||||||
|
|
||||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||||
return accessibilityPreferences{
|
return settingsmodel.DefaultAccessibilityPreferences()
|
||||||
DisplayDensity: displayDensityForDenseLayout(true),
|
|
||||||
Contrast: contrastStandard,
|
|
||||||
KeyboardFocus: keyboardFocusStandard,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayDensityForDenseLayout(dense bool) string {
|
func displayDensityForDenseLayout(dense bool) string {
|
||||||
if dense {
|
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||||
return displayDensityDense
|
|
||||||
}
|
|
||||||
return displayDensityComfortable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||||
normalized := defaultAccessibilityPreferences()
|
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||||
switch prefs.DisplayDensity {
|
|
||||||
case displayDensityDense, displayDensityComfortable:
|
|
||||||
normalized.DisplayDensity = prefs.DisplayDensity
|
|
||||||
}
|
|
||||||
switch prefs.Contrast {
|
|
||||||
case contrastStandard, contrastHigh:
|
|
||||||
normalized.Contrast = prefs.Contrast
|
|
||||||
}
|
|
||||||
switch prefs.KeyboardFocus {
|
|
||||||
case keyboardFocusStandard, keyboardFocusProminent:
|
|
||||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
|
||||||
}
|
|
||||||
normalized.ReducedMotion = prefs.ReducedMotion
|
|
||||||
return normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||||
@@ -102,6 +96,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
|||||||
u.accessibilityPrefs = normalized
|
u.accessibilityPrefs = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
|
appearance := focusAppearance{
|
||||||
|
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||||
|
OutlineColor: color.NRGBA{A: 0},
|
||||||
|
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||||
|
MinHeight: metric.Dp(unit.Dp(44)),
|
||||||
|
}
|
||||||
|
if prefs.DisplayDensity == displayDensityComfortable {
|
||||||
|
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||||
|
}
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
appearance.BorderColor = accentColor
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||||
|
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||||
|
}
|
||||||
|
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
|
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
|
text = accentColor
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||||
|
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||||
|
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return background, text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) accessibilityLabel(id focusID) string {
|
||||||
|
switch {
|
||||||
|
case id == focusSearch:
|
||||||
|
return "Search vault"
|
||||||
|
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
crumbs := u.breadcrumbLabels()
|
||||||
|
if index >= 0 && index < len(crumbs) {
|
||||||
|
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "list:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
if index >= 0 && index < len(u.visible) {
|
||||||
|
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "detail:"):
|
||||||
|
name := strings.TrimPrefix(string(id), "detail:")
|
||||||
|
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||||
|
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
width := appearance.OutlineWidth
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) isFocused(id focusID) bool {
|
||||||
|
return u.keyboardFocus == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailFieldLabel(field detailField) string {
|
||||||
|
return editormodel.Label(field)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) loadSettingsDraft() {
|
func (u *ui) loadSettingsDraft() {
|
||||||
u.settingsDraft = settingsDraft{
|
u.settingsDraft = settingsDraft{
|
||||||
Accessibility: accessibilityPreferences{
|
Accessibility: accessibilityPreferences{
|
||||||
@@ -114,7 +198,11 @@ func (u *ui) loadSettingsDraft() {
|
|||||||
SourceDefault: u.syncDefaultSourceMode,
|
SourceDefault: u.syncDefaultSourceMode,
|
||||||
DirectionDefault: u.syncDefaultDirection,
|
DirectionDefault: u.syncDefaultDirection,
|
||||||
},
|
},
|
||||||
|
Debug: debugSettings{
|
||||||
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) saveSecuritySettingsAction() error {
|
func (u *ui) saveSecuritySettingsAction() error {
|
||||||
@@ -136,9 +224,14 @@ func (u *ui) applySecuritySettingsLive() error {
|
|||||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||||
}
|
}
|
||||||
|
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||||
|
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
u.lastHeaderBoundsLog = ""
|
||||||
|
}
|
||||||
u.applySettingsFormToPreferences()
|
u.applySettingsFormToPreferences()
|
||||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||||
u.saveSettings()
|
u.saveSettings()
|
||||||
@@ -157,6 +250,7 @@ func (u *ui) loadSettings() {
|
|||||||
if json.Unmarshal(content, &settings) == nil {
|
if json.Unmarshal(content, &settings) == nil {
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||||
|
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +287,9 @@ func (u *ui) saveSettings() {
|
|||||||
SourceDefault: string(u.syncDefaultSourceMode),
|
SourceDefault: string(u.syncDefaultSourceMode),
|
||||||
DirectionDefault: string(u.syncDefaultDirection),
|
DirectionDefault: string(u.syncDefaultDirection),
|
||||||
},
|
},
|
||||||
|
Debug: debugSettings{
|
||||||
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
|
},
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -200,6 +297,18 @@ func (u *ui) saveSettings() {
|
|||||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := bounds.logLine(u.mode)
|
||||||
|
if line == u.lastHeaderBoundsLog {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform.LogInfo("KeePassGO", line)
|
||||||
|
u.lastHeaderBoundsLog = line
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) showStatusMessage(message string) {
|
func (u *ui) showStatusMessage(message string) {
|
||||||
u.state.StatusMessage = message
|
u.state.StatusMessage = message
|
||||||
if u.accessibilityPrefs.ReducedMotion {
|
if u.accessibilityPrefs.ReducedMotion {
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 + "."
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
systemclipboard "github.com/atotto/clipboard"
|
systemclipboard "github.com/atotto/clipboard"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/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/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/internal/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"
|
||||||
)
|
)
|
||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -26,6 +27,7 @@ const (
|
|||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
|
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
db.Credentials = credentials
|
db.Credentials = credentials
|
||||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||||
|
db.Content.Meta.CustomData = customDataForModel(model)
|
||||||
db.Content.Root = &gokeepasslib.RootData{}
|
db.Content.Root = &gokeepasslib.RootData{}
|
||||||
if config != nil && config.Header != nil {
|
if config != nil && config.Header != nil {
|
||||||
db.Header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
for _, group := range db.Content.Root.Groups {
|
for _, group := range db.Content.Root.Groups {
|
||||||
appendGroupEntries(&model, db, group, nil)
|
appendGroupEntries(&model, db, group, nil)
|
||||||
}
|
}
|
||||||
|
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||||
|
|
||||||
return model, &KDBXConfig{
|
return model, &KDBXConfig{
|
||||||
Header: cloneHeader(db.Header),
|
Header: cloneHeader(db.Header),
|
||||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||||
|
if len(model.RemoteProfiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := json.Marshal(model.RemoteProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []gokeepasslib.CustomData{{
|
||||||
|
Key: remoteProfilesKey,
|
||||||
|
Value: string(content),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, item := range meta.CustomData {
|
||||||
|
if item.Key != remoteProfilesKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var profiles []RemoteProfile
|
||||||
|
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||||
switch {
|
switch {
|
||||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||||
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
mustGroup("Root",
|
mustGroup("Root",
|
||||||
mustGroup("Internet",
|
mustGroup("Internet",
|
||||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||||
),
|
),
|
||||||
mustGroup("Home Assistant",
|
mustGroup("Security Office",
|
||||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
groups := model.ChildGroups([]string{"Root"})
|
groups := model.ChildGroups([]string{"Root"})
|
||||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
|
||||||
entry.Tags = "automation; home"
|
entry.Tags = "automation; home"
|
||||||
entry.Values = append(entry.Values,
|
entry.Values = append(entry.Values,
|
||||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Home Assistant", entry)),
|
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBX failed: %v", err)
|
t.Fatalf("LoadKDBX failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Password != "token-2" {
|
if got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
||||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||||
Tags: []string{"git", "infra"},
|
Tags: []string{"git", "infra"},
|
||||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "entry-2",
|
ID: "entry-2",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||||
Tags: []string{"automation", "home"},
|
Tags: []string{"automation", "home"},
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||||
}
|
}
|
||||||
|
|
||||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(homeAssistant) != 1 {
|
if len(homeAssistant) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
|
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||||
}
|
}
|
||||||
|
|
||||||
if homeAssistant[0].Password != "token-2" {
|
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := Model{
|
||||||
|
RemoteProfiles: []RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loaded.RemoteProfiles) != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := loaded.RemoteProfiles[0]
|
||||||
|
if got.ID != "bellagio-webdav" || got.Name != "Bellagio Vault" {
|
||||||
|
t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio Vault", got)
|
||||||
|
}
|
||||||
|
if got.Backend != RemoteBackendWebDAV {
|
||||||
|
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||||
|
}
|
||||||
|
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||||
|
}
|
||||||
|
if got.Path != "files/bellagio/keepass.kdbx" {
|
||||||
|
t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
|
||||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loaded.Entries) != 0 {
|
if len(loaded.Entries) != 0 {
|
||||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := model.Search("vault")
|
got := model.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 || got[0].Password != "token-2" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.Search("vault")
|
got := loaded.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "surveillance-console",
|
ID: "surveillance-console",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 || got[0].Password != "token-2" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Attachments: map[string][]byte{
|
Attachments: map[string][]byte{
|
||||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Current credential",
|
Notes: "Current credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
ID: "entry-1-history-1",
|
ID: "entry-1-history-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Original credential",
|
Notes: "Original credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
@@ -8,6 +8,21 @@ import (
|
|||||||
|
|
||||||
var ErrEntryNotFound = errors.New("entry not found")
|
var ErrEntryNotFound = errors.New("entry not found")
|
||||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||||
|
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||||
|
|
||||||
|
type RemoteBackend string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteProfile struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Backend RemoteBackend
|
||||||
|
BaseURL string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -33,6 +48,7 @@ type Model struct {
|
|||||||
Templates []Entry
|
Templates []Entry
|
||||||
RecycleBin []Entry
|
RecycleBin []Entry
|
||||||
Groups [][]string
|
Groups [][]string
|
||||||
|
RemoteProfiles []RemoteProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ChildGroups(path []string) []string {
|
func (m Model) ChildGroups(path []string) []string {
|
||||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
|
|||||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) RemoveEntryByID(id string) bool {
|
||||||
|
for i := range m.Entries {
|
||||||
|
if m.Entries[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||||
|
for _, entry := range m.Entries {
|
||||||
|
if entry.ID == id {
|
||||||
|
return cloneEntry(entry), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Entry{}, ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||||
|
for i := range m.RemoteProfiles {
|
||||||
|
if m.RemoteProfiles[i].ID != profile.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.RemoteProfiles[i] = profile
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) RemoveRemoteProfileByID(id string) bool {
|
||||||
|
for i := range m.RemoteProfiles {
|
||||||
|
if m.RemoteProfiles[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||||
|
for _, profile := range m.RemoteProfiles {
|
||||||
|
if profile.ID == id {
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) UpsertTemplate(entry Entry) {
|
func (m *Model) UpsertTemplate(entry Entry) {
|
||||||
for i := range m.Templates {
|
for i := range m.Templates {
|
||||||
if m.Templates[i].ID != entry.ID {
|
if m.Templates[i].ID != entry.ID {
|
||||||
@@ -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 = r160.5fa79bd
|
pkgver = r165.1c72a50
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://git.julianfamily.org/joejulian/keepassgo
|
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=keepassgo-git
|
pkgname=keepassgo-git
|
||||||
pkgver=r0.0000000
|
pkgver=@PKGVER@
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='KeePass-compatible password manager written in Go'
|
pkgdesc='KeePass-compatible password manager written in Go'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
@@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
|||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
_repo_dir() {
|
_repo_dir() {
|
||||||
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
printf '%s\n' "@REPO_DIR@"
|
||||||
printf '%s\n' "${srcdir}/keepassgo"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "${startdir}/../../.." || exit 1
|
|
||||||
pwd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pkgver() {
|
pkgver() {
|
||||||
@@ -45,16 +39,18 @@ build() {
|
|||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
export GOFLAGS="-trimpath"
|
export GOFLAGS="-trimpath"
|
||||||
go build -o keepassgo .
|
local app_version
|
||||||
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
install -Dm644 assets/keepassgo-icon.png \
|
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 assets/keepassgo-icon.svg \
|
install -Dm644 internal/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"
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# SPDX-License-Identifier: Unlicense OR MIT
|
|
||||||
image: debian/testing
|
|
||||||
packages:
|
|
||||||
- clang
|
|
||||||
- cmake
|
|
||||||
- curl
|
|
||||||
- autoconf
|
|
||||||
- libxml2-dev
|
|
||||||
- libssl-dev
|
|
||||||
- libz-dev
|
|
||||||
- llvm-dev # for cctools
|
|
||||||
- uuid-dev ## for cctools
|
|
||||||
- libplist-utils # for gogio
|
|
||||||
sources:
|
|
||||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
|
||||||
- https://git.sr.ht/~eliasnaur/applesdks
|
|
||||||
- https://git.sr.ht/~eliasnaur/giouiorg
|
|
||||||
- https://github.com/tpoechtrager/cctools-port.git
|
|
||||||
- https://github.com/tpoechtrager/apple-libtapi.git
|
|
||||||
- https://github.com/mackyle/xar.git
|
|
||||||
environment:
|
|
||||||
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
|
|
||||||
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
|
|
||||||
tasks:
|
|
||||||
- install_go: |
|
|
||||||
mkdir -p /home/build/sdk
|
|
||||||
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
|
||||||
- prepare_toolchain: |
|
|
||||||
mkdir -p $APPLE_TOOLCHAIN_ROOT
|
|
||||||
cd $APPLE_TOOLCHAIN_ROOT
|
|
||||||
tar xJf /home/build/applesdks/applesdks.tar.xz
|
|
||||||
mkdir bin tools
|
|
||||||
cd bin
|
|
||||||
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
|
|
||||||
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
|
|
||||||
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
|
|
||||||
ln -s ../tools/appletoolchain xcrun
|
|
||||||
ln -s /usr/bin/plistutil plutil
|
|
||||||
cd ../tools
|
|
||||||
ln -s appletoolchain clang-ios
|
|
||||||
ln -s appletoolchain clang-macos
|
|
||||||
- install_appletoolchain: |
|
|
||||||
cd giouiorg
|
|
||||||
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
|
|
||||||
- build_xar: |
|
|
||||||
cd xar/xar
|
|
||||||
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
|
|
||||||
make
|
|
||||||
sudo make install
|
|
||||||
- build_libtapi: |
|
|
||||||
cd apple-libtapi
|
|
||||||
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
|
|
||||||
./install.sh
|
|
||||||
- build_cctools: |
|
|
||||||
cd cctools-port/cctools
|
|
||||||
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
|
|
||||||
make install
|
|
||||||
- install_gogio: |
|
|
||||||
cd gio-cmd
|
|
||||||
go install ./gogio
|
|
||||||
- test_ios_gogio: |
|
|
||||||
mkdir tmp
|
|
||||||
cd tmp
|
|
||||||
go mod init example.com
|
|
||||||
go get -d gioui.org/example/kitchen
|
|
||||||
export PATH=/home/build/appletools/bin:$PATH
|
|
||||||
gogio -target ios -o app.app gioui.org/example/kitchen
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# SPDX-License-Identifier: Unlicense OR MIT
|
|
||||||
image: freebsd/13.x
|
|
||||||
packages:
|
|
||||||
- libX11
|
|
||||||
- libxkbcommon
|
|
||||||
- libXcursor
|
|
||||||
- libXfixes
|
|
||||||
- vulkan-headers
|
|
||||||
- wayland
|
|
||||||
- mesa-libs
|
|
||||||
- xorg-vfbserver
|
|
||||||
sources:
|
|
||||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
|
||||||
environment:
|
|
||||||
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
|
||||||
tasks:
|
|
||||||
- install_go: |
|
|
||||||
mkdir -p /home/build/sdk
|
|
||||||
curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
|
||||||
- test_cmd: |
|
|
||||||
cd gio-cmd
|
|
||||||
go test ./...
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# SPDX-License-Identifier: Unlicense OR MIT
|
|
||||||
image: debian/bookworm
|
|
||||||
packages:
|
|
||||||
- curl
|
|
||||||
- pkg-config
|
|
||||||
- libwayland-dev
|
|
||||||
- libx11-dev
|
|
||||||
- libx11-xcb-dev
|
|
||||||
- libxkbcommon-dev
|
|
||||||
- libxkbcommon-x11-dev
|
|
||||||
- libgles2-mesa-dev
|
|
||||||
- libegl1-mesa-dev
|
|
||||||
- libffi-dev
|
|
||||||
- libvulkan-dev
|
|
||||||
- libxcursor-dev
|
|
||||||
- libxrandr-dev
|
|
||||||
- libxinerama-dev
|
|
||||||
- libxi-dev
|
|
||||||
- libxxf86vm-dev
|
|
||||||
- mesa-vulkan-drivers
|
|
||||||
- wine
|
|
||||||
- xvfb
|
|
||||||
- xdotool
|
|
||||||
- scrot
|
|
||||||
- sway
|
|
||||||
- grim
|
|
||||||
- wine
|
|
||||||
- unzip
|
|
||||||
sources:
|
|
||||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
|
||||||
environment:
|
|
||||||
PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
|
|
||||||
ANDROID_SDK_ROOT: /home/build/android
|
|
||||||
android_sdk_tools_zip: sdk-tools-linux-3859397.zip
|
|
||||||
android_ndk_zip: android-ndk-r20-linux-x86_64.zip
|
|
||||||
github_mirror: git@github.com:gioui/gio-cmd
|
|
||||||
secrets:
|
|
||||||
- fdc570bf-87f4-4528-8aee-4d1711b1c86f
|
|
||||||
tasks:
|
|
||||||
- install_go: |
|
|
||||||
mkdir -p /home/build/sdk
|
|
||||||
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
|
||||||
- check_gofmt: |
|
|
||||||
cd gio-cmd
|
|
||||||
test -z "$(gofmt -s -l .)"
|
|
||||||
- check_sign_off: |
|
|
||||||
set +x -e
|
|
||||||
cd gio-cmd
|
|
||||||
for hash in $(git log -n 20 --format="%H"); do
|
|
||||||
message=$(git log -1 --format=%B $hash)
|
|
||||||
if [[ ! "$message" =~ "Signed-off-by: " ]]; then
|
|
||||||
echo "Missing 'Signed-off-by' in commit $hash"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
- mirror: |
|
|
||||||
# mirror to github
|
|
||||||
ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
|
|
||||||
- install_chrome: |
|
|
||||||
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
|
||||||
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
|
||||||
sudo apt-get -qq update
|
|
||||||
sudo apt-get -qq install -y google-chrome-stable
|
|
||||||
- test: |
|
|
||||||
cd gio-cmd
|
|
||||||
go test ./...
|
|
||||||
go test -race ./...
|
|
||||||
- install_jdk8: |
|
|
||||||
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
|
|
||||||
sudo apt-get -qq install -y -f ./jdk.deb
|
|
||||||
- install_android: |
|
|
||||||
mkdir android
|
|
||||||
cd android
|
|
||||||
curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
|
|
||||||
unzip -q sdk-tools.zip
|
|
||||||
rm sdk-tools.zip
|
|
||||||
curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
|
|
||||||
unzip -q ndk.zip
|
|
||||||
rm ndk.zip
|
|
||||||
mv android-ndk-* ndk-bundle
|
|
||||||
yes|sdkmanager --licenses
|
|
||||||
sdkmanager "platforms;android-31" "build-tools;32.0.0"
|
|
||||||
- install_gogio: |
|
|
||||||
cd gio-cmd
|
|
||||||
go install ./gogio
|
|
||||||
- test_android_gogio: |
|
|
||||||
mkdir tmp
|
|
||||||
cd tmp
|
|
||||||
go mod init example.com
|
|
||||||
go get -d gioui.org/example/kitchen
|
|
||||||
gogio -target android gioui.org/example/kitchen
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# SPDX-License-Identifier: Unlicense OR MIT
|
|
||||||
image: openbsd/latest
|
|
||||||
packages:
|
|
||||||
- libxkbcommon
|
|
||||||
- go
|
|
||||||
sources:
|
|
||||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
|
||||||
environment:
|
|
||||||
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
|
||||||
tasks:
|
|
||||||
- install_go: |
|
|
||||||
mkdir -p /home/build/sdk
|
|
||||||
curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
|
|
||||||
cd /home/build/sdk/go/src
|
|
||||||
./make.bash
|
|
||||||
- test_cmd: |
|
|
||||||
cd gio-cmd
|
|
||||||
go test ./...
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
This project is provided under the terms of the UNLICENSE or
|
|
||||||
the MIT license denoted by the following SPDX identifier:
|
|
||||||
|
|
||||||
SPDX-License-Identifier: Unlicense OR MIT
|
|
||||||
|
|
||||||
You may use the project under the terms of either license.
|
|
||||||
|
|
||||||
Both licenses are reproduced below.
|
|
||||||
|
|
||||||
----
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2019 The Gio authors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
The UNLICENSE
|
|
||||||
|
|
||||||
This is free and unencumbered software released into the public domain.
|
|
||||||
|
|
||||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
||||||
distribute this software, either in source code form or as a compiled
|
|
||||||
binary, for any purpose, commercial or non-commercial, and by any
|
|
||||||
means.
|
|
||||||
|
|
||||||
In jurisdictions that recognize copyright laws, the author or authors
|
|
||||||
of this software dedicate any and all copyright interest in the
|
|
||||||
software to the public domain. We make this dedication for the benefit
|
|
||||||
of the public at large and to the detriment of our heirs and
|
|
||||||
successors. We intend this dedication to be an overt act of
|
|
||||||
relinquishment in perpetuity of all present and future rights to this
|
|
||||||
software under copyright law.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
||||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
||||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
||||||
OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
For more information, please refer to <https://unlicense.org/>
|
|
||||||
---
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
module gioui.org/cmd
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
|
|
||||||
require (
|
|
||||||
gioui.org v0.8.0
|
|
||||||
github.com/akavel/rsrc v0.10.1
|
|
||||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
|
|
||||||
github.com/chromedp/chromedp v0.5.2
|
|
||||||
golang.org/x/image v0.18.0
|
|
||||||
golang.org/x/sync v0.7.0
|
|
||||||
golang.org/x/text v0.16.0
|
|
||||||
golang.org/x/tools v0.23.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
gioui.org/shader v1.0.8 // indirect
|
|
||||||
github.com/go-text/typesetting v0.2.1 // indirect
|
|
||||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
|
|
||||||
github.com/gobwas/pool v0.2.0 // indirect
|
|
||||||
github.com/gobwas/ws v1.0.2 // indirect
|
|
||||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
|
||||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
|
||||||
golang.org/x/mod v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
|
||||||
)
|
|
||||||