Compare commits
47 Commits
0.1.0
...
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 |
@@ -128,7 +128,7 @@ jobs:
|
||||
fi
|
||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||
go build -ldflags "-X main.appVersion=${app_version}" -o "${out}" .
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
||||
done
|
||||
|
||||
- name: Build APK
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
build/
|
||||
*.apk
|
||||
/keepassgo
|
||||
android/keepassgo-android.jar
|
||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||
packaging/archlinux/keepassgo-git/pkg/
|
||||
packaging/archlinux/keepassgo-git/src/
|
||||
packaging/archlinux/keepassgo-git/keepassgo/
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- 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.
|
||||
- The stacked phone layout is the current preferred phone direction.
|
||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||
- Keep the product feeling like the same application on desktop, Android phone,
|
||||
and Android tablet.
|
||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
||||
user's mental model of the workflow.
|
||||
- Use the same action names, the same primary next steps, and the same workflow
|
||||
order across platforms unless there is a hard platform constraint.
|
||||
- Treat workflow prominence and reachability as product behavior, not visual
|
||||
polish. A feature is not parity-complete if it technically exists but is
|
||||
harder to discover or reach on one platform.
|
||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
||||
than platform-specific workflow branches.
|
||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||
and consistent with the product theme.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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.
|
||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||
- Treat the vault model as local-first across all platforms:
|
||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
||||
that local vault rather than replacing it as the primary object.
|
||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
||||
Android tablet:
|
||||
shared remote configuration belongs in the vault,
|
||||
cross-platform workflow stays the same,
|
||||
and only Android's initial KDBX share/import transport may differ.
|
||||
- Do not persist remote credentials in plaintext app-local state.
|
||||
Keep only non-secret binding metadata outside the vault.
|
||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
||||
design:
|
||||
opening or creating the local vault is the main workflow,
|
||||
and remote setup, remote settings, and remote use attach to that vault.
|
||||
|
||||
## Delivery Discipline
|
||||
|
||||
@@ -114,6 +142,14 @@ These features are product requirements, not “nice to have” ideas.
|
||||
implement the minimum code to satisfy them,
|
||||
verify with `go test ./...` and relevant lint checks,
|
||||
and commit each completed behavior.
|
||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
||||
just feature or label parity.
|
||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
||||
assert the same conceptual next step across desktop, phone, and tablet
|
||||
layouts.
|
||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
||||
emulator or device for the exact flow being changed. Do not count “the same
|
||||
buttons exist somewhere on screen” as sufficient validation.
|
||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||
- If blocked, state the blocker concretely and stop only at that point.
|
||||
|
||||
@@ -122,6 +158,13 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Plan for direct KDBX support.
|
||||
- Plan for direct WebDAV-based workflows.
|
||||
- Avoid adding npm-based or browser-stack dependencies.
|
||||
- Keep remote configuration and synchronization local-first:
|
||||
the app should maintain a live local KDBX cache even when using a remote
|
||||
store, so remote outage does not eliminate vault access.
|
||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
||||
storage.
|
||||
- On Android, use system picker/share mechanisms for local vault import/export
|
||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
||||
|
||||
## Tooling
|
||||
|
||||
@@ -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.
|
||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||
- For the Arch package, treat
|
||||
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
|
||||
as the source of truth and regenerate `PKGBUILD` from the template before
|
||||
building.
|
||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
|
||||
- Do not commit generated binaries.
|
||||
|
||||
@@ -29,12 +29,12 @@ Installed machine prerequisites expected by this repo:
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ...
|
||||
go tool gogio -target android ./cmd/keepassgo ...
|
||||
```
|
||||
|
||||
The Android build uses the branded icon asset at:
|
||||
|
||||
- `assets/keepassgo-icon.png`
|
||||
- `internal/assets/keepassgo-icon.png`
|
||||
|
||||
Note:
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
SIGNPASS ?=
|
||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||
ARCH_REPO_DIR ?= $(CURDIR)
|
||||
|
||||
GOGIO_SIGN_FLAGS :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
@@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
apk: android/keepassgo-android.jar
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||
@@ -42,8 +47,8 @@ apk: android/keepassgo-android.jar
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_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)
|
||||
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
-d "$$tmpdir" \
|
||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||
|
||||
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||
@sed \
|
||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||
|
||||
@@ -38,14 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
||||
Desktop build:
|
||||
|
||||
```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 main.appVersion=v0.0.1" ./...
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
||||
```
|
||||
|
||||
## Arch Linux Package
|
||||
@@ -89,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
||||
Package:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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.
|
||||
They should be treated as usability work, not just polish.
|
||||
|
||||
### Cross-Platform Workflow Parity
|
||||
|
||||
These items are required to keep desktop, Android phone, and Android tablet
|
||||
feeling like the same application rather than three related UIs.
|
||||
|
||||
- Workflow parity:
|
||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
||||
sync, browse entries, and edit entries.
|
||||
- Workflow parity:
|
||||
ensure desktop, phone, and tablet use the same action names and the same
|
||||
primary next steps for those workflows.
|
||||
- Workflow parity:
|
||||
remove or reduce platform-specific workflow exceptions where the same user
|
||||
intent currently takes a different route on different form factors.
|
||||
- Testing:
|
||||
add cross-mode behavior tests that assert workflow order and action
|
||||
prominence, not just label presence.
|
||||
- Testing:
|
||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
||||
action on desktop, phone, and tablet layouts.
|
||||
- Testing:
|
||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
||||
removal are reachable from the same primary affordance family across modes.
|
||||
- Android verification:
|
||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
||||
including with the on-screen keyboard visible.
|
||||
- Android verification:
|
||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
||||
failure, not as acceptable platform variation.
|
||||
|
||||
### Primary Workflow Changes
|
||||
|
||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||
|
||||
@@ -22,3 +22,26 @@
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/keepassgo_accessibility_service" />
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||
android:authorities="org.julianfamily.keepassgo.share"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<activity
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/x-keepass2" />
|
||||
<data android:mimeType="application/vnd.keepass" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -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,10 +13,10 @@ const (
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultLdflags = "-X main.appVersion=dev"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -58,7 +58,7 @@ func (c Config) GogioArgs() []string {
|
||||
"-minsdk", c.MinSDK,
|
||||
"-targetsdk", c.TargetSDK,
|
||||
"-icon", c.IconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||
"-minsdk", DefaultMinSDK,
|
||||
"-targetsdk", DefaultTargetSDK,
|
||||
"-icon", DefaultIconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
|
||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||
|
||||
@@ -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
|
||||
|
||||
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||
|
||||
require (
|
||||
gioui.org 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=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
|
||||
@@ -7,26 +7,26 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"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"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type DirtyProvider func() bool
|
||||
|
||||
type Host struct {
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
}
|
||||
|
||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/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"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -1053,64 +1053,64 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry
|
||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
t.Helper()
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
}
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return newTestClientForModel(t, model)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,20 +22,20 @@ var (
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
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"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
@@ -133,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||
return apitokens.Entries(model)
|
||||
}
|
||||
|
||||
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := slices.Clone(model.RemoteProfiles)
|
||||
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := slices.Clone(model.Entries)
|
||||
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||
}
|
||||
|
||||
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, input)
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "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: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{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" {
|
||||
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{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{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{
|
||||
RecycleBin: []vault.Entry{
|
||||
{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{
|
||||
Entries: []vault.Entry{
|
||||
{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"}},
|
||||
},
|
||||
},
|
||||
@@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
@@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
SelectedEntryID: "vault-console",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||
@@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||
@@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
@@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error {
|
||||
}
|
||||
|
||||
type lifecycleStubSession struct {
|
||||
createCalls int
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
createCalls int
|
||||
model vault.Model
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remoteClient webdav.Client
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||
return vault.Model{}, nil
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||
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
|
||||
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 (
|
||||
"fmt"
|
||||
@@ -10,91 +10,12 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||
)
|
||||
|
||||
func apiOperations() []apitokens.Operation {
|
||||
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, " "))
|
||||
}
|
||||
type apiAuditQuickFilter = apiui.AuditQuickFilter
|
||||
|
||||
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
||||
if len(filters) == 0 {
|
||||
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
||||
}
|
||||
if _, ok := decisionSeen[event.Type]; !ok {
|
||||
decisionSeen[event.Type] = struct{}{}
|
||||
label := apiAuditDecisionLabel(event.Type)
|
||||
label := apiui.AuditDecisionLabel(event.Type)
|
||||
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
||||
}
|
||||
if strings.TrimSpace(string(event.Operation)) == "" {
|
||||
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
||||
continue
|
||||
}
|
||||
operationSeen[event.Operation] = struct{}{}
|
||||
label := apiAuditOperationLabel(event.Operation)
|
||||
label := apiui.AuditOperationLabel(event.Operation)
|
||||
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) {
|
||||
value := apitokens.Operation(strings.TrimSpace(text))
|
||||
for _, operation := range apiOperations() {
|
||||
for _, operation := range apiui.Operations() {
|
||||
if operation == value {
|
||||
return value, nil
|
||||
}
|
||||
@@ -450,7 +371,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||
}
|
||||
filtered := make([]apiaudit.Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
haystack := apiAuditEventSearchTerms(event)
|
||||
haystack := apiui.AuditEventSearchTerms(event)
|
||||
if strings.Contains(haystack, query) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
@@ -785,7 +706,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
||||
click := &buttons[i]
|
||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
}))
|
||||
}
|
||||
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]
|
||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||
}))
|
||||
}
|
||||
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)
|
||||
}),
|
||||
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(func(gtx layout.Context) layout.Dimensions {
|
||||
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 (
|
||||
"fmt"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"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 detailField string
|
||||
type detailField = editormodel.Field
|
||||
|
||||
const (
|
||||
focusSearch focusID = "search"
|
||||
|
||||
detailFieldID detailField = "id"
|
||||
detailFieldTitle detailField = "title"
|
||||
detailFieldUsername detailField = "username"
|
||||
detailFieldPassword detailField = "password"
|
||||
detailFieldURL detailField = "url"
|
||||
detailFieldPath detailField = "path"
|
||||
detailFieldTags detailField = "tags"
|
||||
detailFieldPasswordProfile detailField = "password-profile"
|
||||
detailFieldNotes detailField = "notes"
|
||||
detailFieldFields detailField = "fields"
|
||||
detailFieldHistoryIndex detailField = "history-index"
|
||||
detailFieldID = editormodel.FieldID
|
||||
detailFieldTitle = editormodel.FieldTitle
|
||||
detailFieldUsername = editormodel.FieldUsername
|
||||
detailFieldPassword = editormodel.FieldPassword
|
||||
detailFieldURL = editormodel.FieldURL
|
||||
detailFieldPath = editormodel.FieldPath
|
||||
detailFieldTags = editormodel.FieldTags
|
||||
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
||||
detailFieldNotes = editormodel.FieldNotes
|
||||
detailFieldFields = editormodel.FieldFields
|
||||
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 {
|
||||
@@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID {
|
||||
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 {
|
||||
if u.handleShortcutKey(name, modifiers) {
|
||||
return true
|
||||
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
|
||||
}
|
||||
|
||||
func detailFocusOrder() []detailField {
|
||||
return []detailField{
|
||||
detailFieldID,
|
||||
detailFieldTitle,
|
||||
detailFieldUsername,
|
||||
detailFieldPassword,
|
||||
detailFieldURL,
|
||||
detailFieldPath,
|
||||
detailFieldTags,
|
||||
detailFieldPasswordProfile,
|
||||
detailFieldNotes,
|
||||
detailFieldFields,
|
||||
detailFieldHistoryIndex,
|
||||
}
|
||||
return editormodel.FocusOrder()
|
||||
}
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
@@ -15,13 +15,29 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"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 {
|
||||
busy := u.lifecycleBusy()
|
||||
showLocalChooser := u.showLocalVaultChooser()
|
||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -31,206 +47,14 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
||||
if u.lifecycleMode == "remote" {
|
||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
||||
}
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
|
||||
}),
|
||||
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(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleMode == "remote" {
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.recentVaultList(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
switch {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
return 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 {
|
||||
@@ -249,168 +73,232 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
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 {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.lifecycleAdvancedDisclosure(gtx)
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
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 {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
if busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(u.lifecycleAdvancedCard),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, "Opening Vault...")
|
||||
}
|
||||
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.lifecycleAdvancedHidden {
|
||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) 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)
|
||||
}),
|
||||
)
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
label = "Opening Vault..."
|
||||
}
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, label)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "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)
|
||||
}),
|
||||
)
|
||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||
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) 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()
|
||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||
return compactCard(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(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), " / "))
|
||||
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.clearRemoteSelection, "Open Different Connection")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
lines := make([]string, 0, 3)
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
||||
lines = append(lines, dir)
|
||||
}
|
||||
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
||||
} else {
|
||||
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
||||
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
||||
}
|
||||
if len(lastGroup) > 0 {
|
||||
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
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
|
||||
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(func(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
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 {
|
||||
if !selected {
|
||||
return compactCard(gtx, w)
|
||||
@@ -750,21 +584,6 @@ func normalizedRemoteHost(baseURL string) string {
|
||||
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 {
|
||||
items := u.selectedAttachmentItems()
|
||||
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 {
|
||||
label := "Group Tools"
|
||||
size := unit.Sp(12)
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
size = unit.Sp(11)
|
||||
}
|
||||
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 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(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
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(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
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)
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
func localVaultPathHelpForRuntime(goos string) string {
|
||||
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) {
|
||||
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."
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
return localVaultPathHelpForRuntime(runtime.GOOS)
|
||||
}
|
||||
|
||||
func keyFileHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
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 {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) {
|
||||
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", 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 (
|
||||
"io"
|
||||
@@ -8,23 +8,27 @@ import (
|
||||
gioclipboard "gioui.org/io/clipboard"
|
||||
"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 {
|
||||
mu sync.Mutex
|
||||
pending []string
|
||||
invalidate func()
|
||||
}
|
||||
|
||||
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
if strings.EqualFold(goos, "android") {
|
||||
return &clipboardCommandWriter{invalidate: invalidate}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||
if !ok {
|
||||
return
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package platform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := newPlatformClipboardWriter("android", nil)
|
||||
writer := NewClipboardWriter("android", nil)
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,32 +11,33 @@ import (
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"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 (
|
||||
displayDensityDense = "dense"
|
||||
displayDensityComfortable = "comfortable"
|
||||
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||
|
||||
contrastStandard = "standard"
|
||||
contrastHigh = "high"
|
||||
contrastStandard = settingsmodel.ContrastStandard
|
||||
contrastHigh = settingsmodel.ContrastHigh
|
||||
|
||||
keyboardFocusStandard = "standard"
|
||||
keyboardFocusProminent = "prominent"
|
||||
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||
)
|
||||
|
||||
type accessibilityPreferences struct {
|
||||
DisplayDensity string
|
||||
Contrast string
|
||||
ReducedMotion bool
|
||||
KeyboardFocus string
|
||||
}
|
||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||
|
||||
type settingsFile struct {
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Sync syncSettings `json:"sync,omitempty"`
|
||||
Debug debugSettings `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettings struct {
|
||||
@@ -42,6 +45,10 @@ type syncSettings struct {
|
||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||
}
|
||||
|
||||
type debugSettings struct {
|
||||
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||
}
|
||||
|
||||
type syncSettingsDraft struct {
|
||||
SourceDefault syncSourceMode
|
||||
DirectionDefault syncDirection
|
||||
@@ -50,6 +57,7 @@ type syncSettingsDraft struct {
|
||||
type settingsDraft struct {
|
||||
Accessibility accessibilityPreferences
|
||||
Sync syncSettingsDraft
|
||||
Debug debugSettings
|
||||
}
|
||||
|
||||
type legacySyncPreferences struct {
|
||||
@@ -63,37 +71,23 @@ type choiceSpec struct {
|
||||
Active bool
|
||||
}
|
||||
|
||||
type focusAppearance struct {
|
||||
BorderColor color.NRGBA
|
||||
OutlineColor color.NRGBA
|
||||
OutlineWidth int
|
||||
MinHeight int
|
||||
}
|
||||
|
||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||
return accessibilityPreferences{
|
||||
DisplayDensity: displayDensityForDenseLayout(true),
|
||||
Contrast: contrastStandard,
|
||||
KeyboardFocus: keyboardFocusStandard,
|
||||
}
|
||||
return settingsmodel.DefaultAccessibilityPreferences()
|
||||
}
|
||||
|
||||
func displayDensityForDenseLayout(dense bool) string {
|
||||
if dense {
|
||||
return displayDensityDense
|
||||
}
|
||||
return displayDensityComfortable
|
||||
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||
}
|
||||
|
||||
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
|
||||
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||
}
|
||||
|
||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
@@ -102,6 +96,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||
u.accessibilityPrefs = normalized
|
||||
}
|
||||
|
||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
appearance := focusAppearance{
|
||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||
OutlineColor: color.NRGBA{A: 0},
|
||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||
MinHeight: metric.Dp(unit.Dp(44)),
|
||||
}
|
||||
if prefs.DisplayDensity == displayDensityComfortable {
|
||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||
}
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||
}
|
||||
if focused {
|
||||
appearance.BorderColor = accentColor
|
||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||
if prefs.Contrast == contrastHigh {
|
||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||
}
|
||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||
}
|
||||
}
|
||||
return appearance
|
||||
}
|
||||
|
||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||
text = accentColor
|
||||
if prefs.Contrast == contrastHigh {
|
||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||
}
|
||||
if focused {
|
||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||
}
|
||||
}
|
||||
return background, text
|
||||
}
|
||||
|
||||
func (u *ui) accessibilityLabel(id focusID) string {
|
||||
switch {
|
||||
case id == focusSearch:
|
||||
return "Search vault"
|
||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||
index := focusIndex(id)
|
||||
crumbs := u.breadcrumbLabels()
|
||||
if index >= 0 && index < len(crumbs) {
|
||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||
}
|
||||
case strings.HasPrefix(string(id), "list:"):
|
||||
index := focusIndex(id)
|
||||
if index >= 0 && index < len(u.visible) {
|
||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||
}
|
||||
case strings.HasPrefix(string(id), "detail:"):
|
||||
name := strings.TrimPrefix(string(id), "detail:")
|
||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
width := appearance.OutlineWidth
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
func (u *ui) isFocused(id focusID) bool {
|
||||
return u.keyboardFocus == id
|
||||
}
|
||||
|
||||
func detailFieldLabel(field detailField) string {
|
||||
return editormodel.Label(field)
|
||||
}
|
||||
|
||||
func (u *ui) loadSettingsDraft() {
|
||||
u.settingsDraft = settingsDraft{
|
||||
Accessibility: accessibilityPreferences{
|
||||
@@ -114,7 +198,11 @@ func (u *ui) loadSettingsDraft() {
|
||||
SourceDefault: u.syncDefaultSourceMode,
|
||||
DirectionDefault: u.syncDefaultDirection,
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}
|
||||
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||
}
|
||||
|
||||
func (u *ui) saveSecuritySettingsAction() error {
|
||||
@@ -136,9 +224,14 @@ func (u *ui) applySecuritySettingsLive() error {
|
||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||
}
|
||||
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||
if !u.debugLogHeaderBounds {
|
||||
u.lastHeaderBoundsLog = ""
|
||||
}
|
||||
u.applySettingsFormToPreferences()
|
||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||
u.saveSettings()
|
||||
@@ -157,6 +250,7 @@ func (u *ui) loadSettings() {
|
||||
if json.Unmarshal(content, &settings) == nil {
|
||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -193,6 +287,9 @@ func (u *ui) saveSettings() {
|
||||
SourceDefault: string(u.syncDefaultSourceMode),
|
||||
DirectionDefault: string(u.syncDefaultDirection),
|
||||
},
|
||||
Debug: debugSettings{
|
||||
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -200,6 +297,18 @@ func (u *ui) saveSettings() {
|
||||
_ = 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) {
|
||||
u.state.StatusMessage = message
|
||||
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"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
systemclipboard "github.com/atotto/clipboard"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package vault
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
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.Credentials = credentials
|
||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||
db.Content.Meta.CustomData = customDataForModel(model)
|
||||
db.Content.Root = &gokeepasslib.RootData{}
|
||||
if config != nil && config.Header != nil {
|
||||
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 {
|
||||
appendGroupEntries(&model, db, group, nil)
|
||||
}
|
||||
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||
|
||||
return model, &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
}, 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) {
|
||||
switch {
|
||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
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",
|
||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||
mustGroup("Security Office",
|
||||
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"})
|
||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
||||
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
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.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "token-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
||||
if got[0].Password != "bellagio-pass-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." {
|
||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
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")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
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" {
|
||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
||||
if homeAssistant[0].Password != "bellagio-pass-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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
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")
|
||||
}
|
||||
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
||||
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 Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
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)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
||||
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
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",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -8,6 +8,21 @@ import (
|
||||
|
||||
var ErrEntryNotFound = errors.New("entry not found")
|
||||
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 {
|
||||
ID string
|
||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry 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) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != entry.ID {
|
||||
@@ -13,10 +13,10 @@ type SecuritySettings struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CipherAES256 = "aes256"
|
||||
CipherAES256 = "aes256"
|
||||
CipherChaCha20 = "chacha20"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
)
|
||||
|
||||
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = keepassgo-git
|
||||
pkgdesc = KeePass-compatible password manager written in Go
|
||||
pkgver = r160.5fa79bd
|
||||
pkgver = r165.1c72a50
|
||||
pkgrel = 1
|
||||
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||
arch = x86_64
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=keepassgo-git
|
||||
pkgver=r0.0000000
|
||||
pkgver=@PKGVER@
|
||||
pkgrel=1
|
||||
pkgdesc='KeePass-compatible password manager written in Go'
|
||||
arch=('x86_64' 'aarch64')
|
||||
@@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
||||
sha256sums=('SKIP')
|
||||
|
||||
_repo_dir() {
|
||||
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
||||
printf '%s\n' "${srcdir}/keepassgo"
|
||||
return
|
||||
fi
|
||||
|
||||
cd "${startdir}/../../.." || exit 1
|
||||
pwd
|
||||
printf '%s\n' "@REPO_DIR@"
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
@@ -47,16 +41,16 @@ build() {
|
||||
export GOFLAGS="-trimpath"
|
||||
local app_version
|
||||
app_version="$(git describe --tags --always --dirty)"
|
||||
go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo .
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$(_repo_dir)"
|
||||
|
||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||
install -Dm644 assets/keepassgo-icon.png \
|
||||
install -Dm644 internal/assets/keepassgo-icon.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"
|
||||
install -Dm644 packaging/archlinux/keepassgo-git/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
|
||||
)
|
||||