diff --git a/.gitignore b/.gitignore
index 125a61f..5564ee6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
build/
*.apk
+keepassgo
+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/
diff --git a/AGENTS.md b/AGENTS.md
index 97599c8..8f8cfc6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,6 +95,9 @@ 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.
+- 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
diff --git a/Makefile b/Makefile
index a5056a8..53ecf78 100644
--- a/Makefile
+++ b/Makefile
@@ -11,6 +11,11 @@ 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; }
@@ -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)"
diff --git a/android/application_snippets.xml b/android/application_snippets.xml
index 0990299..8889103 100644
--- a/android/application_snippets.xml
+++ b/android/application_snippets.xml
@@ -22,3 +22,26 @@
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar
index 89509e5..556c6a8 100644
Binary files a/android/keepassgo-android.jar and b/android/keepassgo-android.jar differ
diff --git a/android_share_android.go b/android_share_android.go
new file mode 100644
index 0000000..865520e
--- /dev/null
+++ b/android_share_android.go
@@ -0,0 +1,184 @@
+//go:build android
+
+package main
+
+/*
+#cgo CFLAGS: -Werror
+#cgo LDFLAGS: -landroid
+
+#include
+#include
+
+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 newPlatformVaultSharer(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)
+}
diff --git a/android_share_stub.go b/android_share_stub.go
new file mode 100644
index 0000000..80a101f
--- /dev/null
+++ b/android_share_stub.go
@@ -0,0 +1,7 @@
+//go:build !android
+
+package main
+
+func newPlatformVaultSharer(goos string) vaultSharer {
+ return nil
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/AndroidShare.java b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java
new file mode 100644
index 0000000..9f62da8
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java
@@ -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;
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
new file mode 100644
index 0000000..b669fa7
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
@@ -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);
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java
new file mode 100644
index 0000000..2793516
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java
@@ -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();
+ }
+}
diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go
new file mode 100644
index 0000000..f460c56
--- /dev/null
+++ b/appstate/remote_binding.go
@@ -0,0 +1,141 @@
+package appstate
+
+import (
+ "fmt"
+ "strings"
+
+ "git.julianfamily.org/keepassgo/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
+ }
+}
diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go
new file mode 100644
index 0000000..36009f2
--- /dev/null
+++ b/appstate/remote_binding_test.go
@@ -0,0 +1,250 @@
+package appstate
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "git.julianfamily.org/keepassgo/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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ },
+ },
+ }
+
+ binding := RemoteBinding{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-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/family/keepass.kdbx" {
+ t.Fatalf("resolved profile path = %q, want files/family/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/family.kdbx",
+ RemoteProfileID: "family-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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }}
+
+ _, err = (RemoteBinding{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-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/family.kdbx",
+ RemoteProfileID: "family-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/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ RemoteProfileName: "Family Vault",
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ RemotePath: "files/family/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/family.kdbx" {
+ t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath)
+ }
+ if binding.RemoteProfileID != "family-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: "family-webdav",
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ RemotePath: "files/family/keepass.kdbx",
+ CredentialEntryID: "remote-creds-1",
+ Password: "bellagio-pass-1",
+ },
+ },
+ {
+ name: "missing_remote_base_url",
+ input: RemoteBindingInput{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ RemotePath: "files/family/keepass.kdbx",
+ CredentialEntryID: "remote-creds-1",
+ Password: "bellagio-pass-1",
+ },
+ },
+ {
+ name: "missing_credential_entry_id",
+ input: RemoteBindingInput{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ RemotePath: "files/family/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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+
+ err := RemoveRemoteBinding(&model, RemoteBinding{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-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)
+ }
+}
diff --git a/appstate/state.go b/appstate/state.go
index 45dcc1a..7dfdc7f 100644
--- a/appstate/state.go
+++ b/appstate/state.go
@@ -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 {
diff --git a/appstate/state_test.go b/appstate/state_test.go
index 270d5f3..3bf8e1c 100644
--- a/appstate/state_test.go
+++ b/appstate/state_test.go
@@ -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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ },
+ {
+ ID: "archive-webdav",
+ Name: "Archive Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/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 != "family-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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ },
+ },
+ },
+ }
+ state := State{
+ Session: sess,
+ CurrentPath: []string{"Root", "Internet"},
+ SelectedEntryID: "vault-console",
+ Dirty: true,
+ }
+
+ err := state.OpenBoundRemoteVault(RemoteBinding{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-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/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want files/family/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/family.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/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ RemoteProfileName: "Family Vault",
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ RemotePath: "files/family/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 != "family-webdav" {
+ t.Fatalf("binding.RemoteProfileID = %q, want family-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/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ RemotePath: "files/family/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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }}
+ state := State{Session: sess}
+
+ err := state.RemoveRemoteBinding(RemoteBinding{
+ LocalVaultPath: "/tmp/family.kdbx",
+ RemoteProfileID: "family-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
}
diff --git a/docs/local-first-remote-sync-plan.md b/docs/local-first-remote-sync-plan.md
new file mode 100644
index 0000000..2f2a260
--- /dev/null
+++ b/docs/local-first-remote-sync-plan.md
@@ -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.
diff --git a/main.go b/main.go
index 6c47aa0..3a6b559 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,8 @@
package main
import (
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
"flag"
@@ -135,12 +137,14 @@ type attachmentItem struct {
}
type statePaths struct {
- DefaultSaveAsPath string
- RecentVaultsPath string
- RecentRemotesPath string
- SettingsPath string
- UIPreferencesPath string
- AutofillCachePath string
+ DefaultSaveAsPath string
+ RecentVaultsPath string
+ RecentRemotesPath string
+ SettingsPath string
+ UIPreferencesPath string
+ AutofillCachePath string
+ PendingSharedVaultPath string
+ PendingSharedVaultNamePath string
}
type recentVaultRecord struct {
@@ -150,12 +154,17 @@ type recentVaultRecord struct {
}
type recentRemoteRecord struct {
- BaseURL string `json:"baseUrl"`
- Path string `json:"path"`
- Username string `json:"username,omitempty"`
- Password string `json:"password,omitempty"`
- LastGroup []string `json:"lastGroup,omitempty"`
- UsedAt string `json:"usedAt,omitempty"`
+ BaseURL string `json:"baseUrl"`
+ Path string `json:"path"`
+ LocalVaultPath string `json:"localVaultPath,omitempty"`
+ RemoteProfileID string `json:"remoteProfileId,omitempty"`
+ CredentialEntryID string `json:"credentialEntryId,omitempty"`
+ SyncMode string `json:"syncMode,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ LastGroup []string `json:"lastGroup,omitempty"`
+ UsedAt string `json:"usedAt,omitempty"`
+ NeedsMigration bool `json:"-"`
}
type uiPreferences struct {
@@ -208,273 +217,297 @@ const (
syncDirectionPush syncDirection = "push"
)
+type syncDialogPurpose string
+
+const (
+ syncDialogPurposeAdvanced syncDialogPurpose = "advanced"
+ syncDialogPurposeRemoteSetup syncDialogPurpose = "remote-setup"
+)
+
type ui struct {
- mode string
- theme *material.Theme
- fileExplorer *explorer.Explorer
- logoHorizontal paint.ImageOp
- splashSquare paint.ImageOp
- search widget.Editor
- vaultPath widget.Editor
- saveAsPath widget.Editor
- remoteBaseURL widget.Editor
- remotePath widget.Editor
- remoteUsername widget.Editor
- remotePassword widget.Editor
- masterPassword widget.Editor
- keyFilePath widget.Editor
- apiTokenName widget.Editor
- apiTokenClientName widget.Editor
- apiTokenExpiresAt widget.Editor
- apiPolicyOperation widget.Editor
- apiPolicyPath widget.Editor
- apiPolicyEntryID widget.Editor
- securityCipher widget.Editor
- securityKDF widget.Editor
- entryID widget.Editor
- entryTitle widget.Editor
- entryUsername widget.Editor
- entryPassword widget.Editor
- entryURL widget.Editor
- entryNotes widget.Editor
- entryTags widget.Editor
- entryPath widget.Editor
- entryFields widget.Editor
- customFieldKeys []widget.Editor
- customFieldValues []widget.Editor
- historyIndex widget.Editor
- groupName widget.Editor
- groupParentPath widget.Editor
- passwordProfile widget.Editor
- attachmentName widget.Editor
- attachmentPath widget.Editor
- exportAttachmentPath widget.Editor
- autofillBrowserAllowlist widget.Editor
- autofillAppAllowlist widget.Editor
- autofillPackageRules widget.Editor
- list widget.List
- groupList widget.List
- detailList widget.List
- apiPolicyList widget.List
- lifecycleList widget.List
- phonePanelList widget.List
- securityDialogList widget.List
- remotePrefsDialogList widget.List
- recentVaultListState widget.List
- recentRemoteListState widget.List
- copyUser widget.Clickable
- copyPass widget.Clickable
- copyURL widget.Clickable
- lockVault widget.Clickable
- unlockVault widget.Clickable
- createVault widget.Clickable
- openVault widget.Clickable
- saveVault widget.Clickable
- saveAsVault widget.Clickable
- openRemote widget.Clickable
- changeMasterKey widget.Clickable
- synchronizeVault widget.Clickable
- toggleSyncMenu widget.Clickable
- toggleMainMenu widget.Clickable
- openAdvancedSync widget.Clickable
- openSecuritySettings widget.Clickable
- openRemotePrefsHelp widget.Clickable
- closeAdvancedSync widget.Clickable
- closeSecuritySettings widget.Clickable
- closeRemotePrefsHelp widget.Clickable
- runAdvancedSync widget.Clickable
- saveSecuritySettings widget.Clickable
- settingsDensityDense widget.Clickable
- settingsDensityComfortable widget.Clickable
- settingsContrastStandard widget.Clickable
- settingsContrastHigh widget.Clickable
- settingsReducedMotionOff widget.Clickable
- settingsReducedMotionOn widget.Clickable
- settingsKeyboardFocusStandard widget.Clickable
- settingsKeyboardFocusProminent widget.Clickable
- showSettingsSyncLocal widget.Clickable
- showSettingsSyncRemote widget.Clickable
- showSettingsSyncPull widget.Clickable
- showSettingsSyncPush widget.Clickable
- editEntry widget.Clickable
- cancelEdit widget.Clickable
- pickVaultPath widget.Clickable
- pickKeyFile widget.Clickable
- pickSyncLocalPath widget.Clickable
- clearVaultSelection widget.Clickable
- clearRemoteSelection widget.Clickable
- dismissBanner widget.Clickable
- addEntry widget.Clickable
- saveEntry widget.Clickable
- duplicateEntry widget.Clickable
- deleteEntry widget.Clickable
- restoreEntry widget.Clickable
- saveTemplate widget.Clickable
- deleteTemplate widget.Clickable
- instantiateTemplate widget.Clickable
- addAttachment widget.Clickable
- replaceAttachment widget.Clickable
- removeAttachment widget.Clickable
- exportAttachment widget.Clickable
- restoreHistory widget.Clickable
- generatePassword widget.Clickable
- goToRootGroup widget.Clickable
- goToParentGroup widget.Clickable
- createGroup widget.Clickable
- moveGroup widget.Clickable
- renameGroup widget.Clickable
- deleteGroup widget.Clickable
- confirmDeleteGroup widget.Clickable
- cancelDeleteGroup widget.Clickable
- addCustomField widget.Clickable
- toggleGroupControls widget.Clickable
- toggleLifecycleAdvanced widget.Clickable
- toggleHistory widget.Clickable
- togglePasswordInline widget.Clickable
- toggleSyncPassword widget.Clickable
- setStatusBannerShort widget.Clickable
- setStatusBannerStandard widget.Clickable
- setStatusBannerLong widget.Clickable
- showAllAutofillNotices widget.Clickable
- showApprovalAutofillOnly widget.Clickable
- hideAutofillNotices widget.Clickable
- showEntries widget.Clickable
- showTemplates widget.Clickable
- showRecycle widget.Clickable
- showAPITokens widget.Clickable
- showAPIAudit widget.Clickable
- showAbout widget.Clickable
- showLocalLifecycle widget.Clickable
- showRemoteLifecycle widget.Clickable
- showSyncLocal widget.Clickable
- showSyncRemote widget.Clickable
- showSyncPull widget.Clickable
- showSyncPush widget.Clickable
- showAutofillApprovalAsk widget.Clickable
- showAutofillApprovalAllow widget.Clickable
- showAutofillApprovalBlock widget.Clickable
- allowApproval widget.Clickable
- denyApproval widget.Clickable
- cancelApproval widget.Clickable
- cancelLifecycleProgress widget.Clickable
- retryLifecycleOpen widget.Clickable
- approvalPermanent widget.Bool
- rememberRemoteAuth widget.Bool
- apiPolicyAllow widget.Bool
- apiPolicyGroupScopeW widget.Bool
- apiTokenDisabled widget.Bool
- settingsGroupControls widget.Bool
- settingsLifecycleAdvanced widget.Bool
- settingsHistory widget.Bool
- settingsDenseLayout widget.Bool
- entryClicks []widget.Clickable
- apiTokenClicks []widget.Clickable
- apiPolicyRemoves []widget.Clickable
- apiAuditClicks []widget.Clickable
- apiAuditTokenFilters []widget.Clickable
- apiAuditDecisionFilters []widget.Clickable
- apiAuditOperationFilters []widget.Clickable
- clearAPIAuditFilters widget.Clickable
- historyClicks []widget.Clickable
- attachmentClicks []widget.Clickable
- breadcrumbs []widget.Clickable
- groupClicks []widget.Clickable
- recentVaultClicks []widget.Clickable
- recentRemoteClicks []widget.Clickable
- removeCustomFields []widget.Clickable
- state appstate.State
- visible []entry
- currentPath []string
- syncedPath []string
- selectedHistoryIndex int
- showPassword bool
- generatedPasswordDraft bool
- togglePassword widget.Clickable
- copyAPITokenSecret widget.Clickable
- issueAPIToken widget.Clickable
- saveAPIToken widget.Clickable
- rotateAPIToken widget.Clickable
- disableAPIToken widget.Clickable
- revokeAPIToken widget.Clickable
- deleteAPIToken widget.Clickable
- useCurrentGroupForPolicy widget.Clickable
- useSelectedEntryForPolicy widget.Clickable
- clearAPIPolicyTarget widget.Clickable
- addAPIPolicyRule widget.Clickable
- phoneSplit widget.Float
- splitDrag gesture.Drag
- splitBase float32
- splitStartY float32
- phoneSpan int
- phoneGroupBrowserExpanded bool
- eyeIcon *widget.Icon
- eyeOffIcon *widget.Icon
- copyIcon *widget.Icon
- expandMoreIcon *widget.Icon
- expandLessIcon *widget.Icon
- chevronRightIcon *widget.Icon
- chevronDownIcon *widget.Icon
- settingsIcon *widget.Icon
- menuIcon *widget.Icon
- clipboardWriter clipboard.Writer
- loadingMessage string
- loadingActionLabel string
- lifecycleMode string
- syncSourceMode syncSourceMode
- syncDirection syncDirection
- syncLocalImportName string
- syncLocalImportContent []byte
- syncLocalPath widget.Editor
- syncRemoteBaseURL widget.Editor
- syncRemotePath widget.Editor
- syncRemoteUsername widget.Editor
- syncRemotePassword widget.Editor
- syncDialogOpen bool
- syncMenuOpen bool
- mainMenuOpen bool
- selectedRemoteConnection bool
- securityDialogOpen bool
- remotePrefsDialogOpen bool
- showSyncPassword bool
- keyboardFocus focusID
- defaultSaveAsPath string
- recentVaultsPath string
- settingsPath string
- uiPreferencesPath string
- recentRemotesPath string
- autofillCachePath string
- editingEntry bool
- syncDefaultSourceMode syncSourceMode
- syncDefaultDirection syncDirection
- groupControlsHidden bool
- lifecycleAdvancedHidden bool
- historyHidden bool
- denseLayout bool
- statusBannerTTL time.Duration
- autofillNoticePreference autofillNoticeMode
- autofillFirstFillApprovalMode autofillFirstFillApprovalMode
- accessibilityPrefs accessibilityPreferences
- settingsDraft settingsDraft
- recentVaults []string
- recentRemotes []recentRemoteRecord
- recentVaultGroups map[string][]string
- recentVaultUsedAt map[string]time.Time
- entriesState entriesSectionState
- deleteGroupPath []string
- apiPolicyGroupScope bool
- apiTokenSecret string
- selectedAuditIndex int
- statusExpiresAt time.Time
- now func() time.Time
- apiHost *api.Host
- auditLog *apiaudit.Log
- grpcAddress string
- backgroundResults chan backgroundActionResult
- backgroundActionSerial int
- activeBackgroundAction int
- lastLifecycleAction string
- requestMasterPassFocus bool
- invalidate func()
+ mode string
+ theme *material.Theme
+ fileExplorer *explorer.Explorer
+ logoHorizontal paint.ImageOp
+ splashSquare paint.ImageOp
+ search widget.Editor
+ vaultPath widget.Editor
+ saveAsPath widget.Editor
+ remoteBaseURL widget.Editor
+ remotePath widget.Editor
+ remoteUsername widget.Editor
+ remotePassword widget.Editor
+ masterPassword widget.Editor
+ keyFilePath widget.Editor
+ apiTokenName widget.Editor
+ apiTokenClientName widget.Editor
+ apiTokenExpiresAt widget.Editor
+ apiPolicyOperation widget.Editor
+ apiPolicyPath widget.Editor
+ apiPolicyEntryID widget.Editor
+ securityCipher widget.Editor
+ securityKDF widget.Editor
+ entryID widget.Editor
+ entryTitle widget.Editor
+ entryUsername widget.Editor
+ entryPassword widget.Editor
+ entryURL widget.Editor
+ entryNotes widget.Editor
+ entryTags widget.Editor
+ entryPath widget.Editor
+ entryFields widget.Editor
+ customFieldKeys []widget.Editor
+ customFieldValues []widget.Editor
+ historyIndex widget.Editor
+ groupName widget.Editor
+ groupParentPath widget.Editor
+ passwordProfile widget.Editor
+ attachmentName widget.Editor
+ attachmentPath widget.Editor
+ exportAttachmentPath widget.Editor
+ autofillBrowserAllowlist widget.Editor
+ autofillAppAllowlist widget.Editor
+ autofillPackageRules widget.Editor
+ list widget.List
+ groupList widget.List
+ detailList widget.List
+ apiPolicyList widget.List
+ lifecycleList widget.List
+ phonePanelList widget.List
+ securityDialogList widget.List
+ remotePrefsDialogList widget.List
+ recentVaultListState widget.List
+ recentRemoteListState widget.List
+ copyUser widget.Clickable
+ copyPass widget.Clickable
+ copyURL widget.Clickable
+ lockVault widget.Clickable
+ unlockVault widget.Clickable
+ createVault widget.Clickable
+ openVault widget.Clickable
+ saveVault widget.Clickable
+ saveAsVault widget.Clickable
+ openRemote widget.Clickable
+ changeMasterKey widget.Clickable
+ synchronizeVault widget.Clickable
+ toggleSyncMenu widget.Clickable
+ toggleMainMenu widget.Clickable
+ openAdvancedSync widget.Clickable
+ useSavedAdvancedSyncRemote widget.Clickable
+ openSelectedVaultRemote widget.Clickable
+ saveCurrentRemoteBinding widget.Clickable
+ removeSelectedRemoteBinding widget.Clickable
+ openSecuritySettings widget.Clickable
+ openRemotePrefsHelp widget.Clickable
+ closeAdvancedSync widget.Clickable
+ closeSecuritySettings widget.Clickable
+ closeRemotePrefsHelp widget.Clickable
+ runAdvancedSync widget.Clickable
+ saveSecuritySettings widget.Clickable
+ settingsDensityDense widget.Clickable
+ settingsDensityComfortable widget.Clickable
+ settingsContrastStandard widget.Clickable
+ settingsContrastHigh widget.Clickable
+ settingsReducedMotionOff widget.Clickable
+ settingsReducedMotionOn widget.Clickable
+ settingsKeyboardFocusStandard widget.Clickable
+ settingsKeyboardFocusProminent widget.Clickable
+ showSettingsSyncLocal widget.Clickable
+ showSettingsSyncRemote widget.Clickable
+ showSettingsSyncPull widget.Clickable
+ showSettingsSyncPush widget.Clickable
+ editEntry widget.Clickable
+ cancelEdit widget.Clickable
+ pickVaultPath widget.Clickable
+ importSharedVault widget.Clickable
+ shareCurrentVault widget.Clickable
+ pickKeyFile widget.Clickable
+ pickSyncLocalPath widget.Clickable
+ clearVaultSelection widget.Clickable
+ clearRemoteSelection widget.Clickable
+ dismissBanner widget.Clickable
+ addEntry widget.Clickable
+ saveEntry widget.Clickable
+ duplicateEntry widget.Clickable
+ deleteEntry widget.Clickable
+ restoreEntry widget.Clickable
+ saveTemplate widget.Clickable
+ deleteTemplate widget.Clickable
+ instantiateTemplate widget.Clickable
+ addAttachment widget.Clickable
+ replaceAttachment widget.Clickable
+ removeAttachment widget.Clickable
+ exportAttachment widget.Clickable
+ restoreHistory widget.Clickable
+ generatePassword widget.Clickable
+ goToRootGroup widget.Clickable
+ goToParentGroup widget.Clickable
+ createGroup widget.Clickable
+ moveGroup widget.Clickable
+ renameGroup widget.Clickable
+ deleteGroup widget.Clickable
+ confirmDeleteGroup widget.Clickable
+ cancelDeleteGroup widget.Clickable
+ addCustomField widget.Clickable
+ toggleGroupControls widget.Clickable
+ toggleLifecycleAdvanced widget.Clickable
+ toggleHistory widget.Clickable
+ togglePasswordInline widget.Clickable
+ toggleSyncPassword widget.Clickable
+ setStatusBannerShort widget.Clickable
+ setStatusBannerStandard widget.Clickable
+ setStatusBannerLong widget.Clickable
+ showAllAutofillNotices widget.Clickable
+ showApprovalAutofillOnly widget.Clickable
+ hideAutofillNotices widget.Clickable
+ showEntries widget.Clickable
+ showTemplates widget.Clickable
+ showRecycle widget.Clickable
+ showAPITokens widget.Clickable
+ showAPIAudit widget.Clickable
+ showAbout widget.Clickable
+ showLocalLifecycle widget.Clickable
+ showRemoteLifecycle widget.Clickable
+ showSyncLocal widget.Clickable
+ showSyncRemote widget.Clickable
+ showSyncPull widget.Clickable
+ showSyncPush widget.Clickable
+ showAutofillApprovalAsk widget.Clickable
+ showAutofillApprovalAllow widget.Clickable
+ showAutofillApprovalBlock widget.Clickable
+ allowApproval widget.Clickable
+ denyApproval widget.Clickable
+ cancelApproval widget.Clickable
+ cancelLifecycleProgress widget.Clickable
+ retryLifecycleOpen widget.Clickable
+ approvalPermanent widget.Bool
+ syncSetupAutomatic widget.Bool
+ apiPolicyAllow widget.Bool
+ apiPolicyGroupScopeW widget.Bool
+ apiTokenDisabled widget.Bool
+ settingsGroupControls widget.Bool
+ settingsLifecycleAdvanced widget.Bool
+ settingsHistory widget.Bool
+ settingsDenseLayout widget.Bool
+ entryClicks []widget.Clickable
+ apiTokenClicks []widget.Clickable
+ apiPolicyRemoves []widget.Clickable
+ apiAuditClicks []widget.Clickable
+ apiAuditTokenFilters []widget.Clickable
+ apiAuditDecisionFilters []widget.Clickable
+ apiAuditOperationFilters []widget.Clickable
+ clearAPIAuditFilters widget.Clickable
+ historyClicks []widget.Clickable
+ attachmentClicks []widget.Clickable
+ breadcrumbs []widget.Clickable
+ groupClicks []widget.Clickable
+ recentVaultClicks []widget.Clickable
+ recentRemoteClicks []widget.Clickable
+ vaultRemoteProfileClicks []widget.Clickable
+ vaultRemoteCredentialClicks []widget.Clickable
+ syncRemoteCredentialClicks []widget.Clickable
+ removeCustomFields []widget.Clickable
+ state appstate.State
+ visible []entry
+ currentPath []string
+ syncedPath []string
+ selectedHistoryIndex int
+ showPassword bool
+ generatedPasswordDraft bool
+ togglePassword widget.Clickable
+ copyAPITokenSecret widget.Clickable
+ issueAPIToken widget.Clickable
+ saveAPIToken widget.Clickable
+ rotateAPIToken widget.Clickable
+ disableAPIToken widget.Clickable
+ revokeAPIToken widget.Clickable
+ deleteAPIToken widget.Clickable
+ useCurrentGroupForPolicy widget.Clickable
+ useSelectedEntryForPolicy widget.Clickable
+ clearAPIPolicyTarget widget.Clickable
+ addAPIPolicyRule widget.Clickable
+ phoneSplit widget.Float
+ splitDrag gesture.Drag
+ splitBase float32
+ splitStartY float32
+ phoneSpan int
+ phoneGroupBrowserExpanded bool
+ eyeIcon *widget.Icon
+ eyeOffIcon *widget.Icon
+ copyIcon *widget.Icon
+ expandMoreIcon *widget.Icon
+ expandLessIcon *widget.Icon
+ chevronRightIcon *widget.Icon
+ chevronDownIcon *widget.Icon
+ settingsIcon *widget.Icon
+ menuIcon *widget.Icon
+ clipboardWriter clipboard.Writer
+ vaultSharer vaultSharer
+ loadingMessage string
+ loadingActionLabel string
+ lifecycleMode string
+ syncSourceMode syncSourceMode
+ syncDirection syncDirection
+ syncLocalImportName string
+ syncLocalImportContent []byte
+ syncLocalPath widget.Editor
+ syncRemoteBaseURL widget.Editor
+ syncRemotePath widget.Editor
+ syncRemoteUsername widget.Editor
+ syncRemotePassword widget.Editor
+ selectedSyncRemoteCredentialEntryID string
+ syncDialogPurpose syncDialogPurpose
+ syncDialogOpen bool
+ syncMenuOpen bool
+ mainMenuOpen bool
+ selectedRemoteConnection bool
+ selectedVaultRemoteProfileID string
+ selectedVaultRemoteCredentialEntryID string
+ selectedVaultRemoteSyncMode appstate.SyncMode
+ securityDialogOpen bool
+ remotePrefsDialogOpen bool
+ showSyncPassword bool
+ keyboardFocus focusID
+ defaultSaveAsPath string
+ recentVaultsPath string
+ settingsPath string
+ uiPreferencesPath string
+ recentRemotesPath string
+ autofillCachePath string
+ pendingSharedVaultPath string
+ pendingSharedVaultNamePath string
+ editingEntry bool
+ syncDefaultSourceMode syncSourceMode
+ syncDefaultDirection syncDirection
+ groupControlsHidden bool
+ lifecycleAdvancedHidden bool
+ historyHidden bool
+ denseLayout bool
+ statusBannerTTL time.Duration
+ autofillNoticePreference autofillNoticeMode
+ autofillFirstFillApprovalMode autofillFirstFillApprovalMode
+ accessibilityPrefs accessibilityPreferences
+ settingsDraft settingsDraft
+ recentVaults []string
+ recentRemotes []recentRemoteRecord
+ recentVaultGroups map[string][]string
+ recentVaultUsedAt map[string]time.Time
+ entriesState entriesSectionState
+ deleteGroupPath []string
+ apiPolicyGroupScope bool
+ apiTokenSecret string
+ selectedAuditIndex int
+ statusExpiresAt time.Time
+ now func() time.Time
+ apiHost *api.Host
+ auditLog *apiaudit.Log
+ grpcAddress string
+ backgroundResults chan backgroundActionResult
+ backgroundActionSerial int
+ activeBackgroundAction int
+ lastLifecycleAction string
+ requestMasterPassFocus bool
+ invalidate func()
}
type backgroundActionResult struct {
@@ -484,6 +517,10 @@ type backgroundActionResult struct {
id int
}
+type vaultSharer interface {
+ ShareVault(path, title string) error
+}
+
var (
bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255}
panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255}
@@ -606,6 +643,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath,
autofillCachePath: paths.AutofillCachePath,
+ pendingSharedVaultPath: paths.PendingSharedVaultPath,
+ pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true,
@@ -620,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
syncDefaultDirection: syncDirectionPull,
apiPolicyGroupScope: true,
autofillNoticePreference: autofillNoticeAll,
+ vaultSharer: newPlatformVaultSharer(runtime.GOOS),
backgroundResults: make(chan backgroundActionResult, 8),
phoneGroupBrowserExpanded: true,
}
@@ -646,6 +686,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.setCustomFieldRows(nil)
u.loadRecentVaults()
u.loadRecentRemotes()
+ if u.hasLegacyRecentRemoteCredentialMigration() {
+ u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
+ }
+ u.consumePendingSharedVaultImport()
u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences()
@@ -716,12 +760,14 @@ func defaultStatePaths(stateDir string) statePaths {
baseDir = filepath.Join(configDir, "keepassgo")
}
return statePaths{
- DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
- RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
- RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
- SettingsPath: filepath.Join(baseDir, "settings.json"),
- UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
- AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
+ DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
+ SettingsPath: filepath.Join(baseDir, "settings.json"),
+ UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
+ AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
+ PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
+ PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
}
}
@@ -750,6 +796,14 @@ func supportsDesktopFilePicker(goos string) bool {
return !strings.EqualFold(strings.TrimSpace(goos), "android")
}
+func supportsSharedVaultImport(goos string) bool {
+ return strings.EqualFold(strings.TrimSpace(goos), "android")
+}
+
+func supportsVaultShare(goos string) bool {
+ return strings.EqualFold(strings.TrimSpace(goos), "android")
+}
+
func (u *ui) selectedAttachmentItems() []attachmentItem {
item, ok := u.selectedEntry()
if !ok || len(item.Attachments) == 0 {
@@ -971,6 +1025,13 @@ func (u *ui) createVaultAction() error {
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
}
@@ -1002,6 +1063,10 @@ func (u *ui) openVaultAction() error {
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()
@@ -1039,6 +1104,10 @@ func (u *ui) startOpenVaultAction() {
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()
@@ -1051,6 +1120,9 @@ 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
}
@@ -1072,6 +1144,22 @@ func (u *ui) openRemoteAction() error {
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()),
@@ -1080,12 +1168,12 @@ func (u *ui) openRemoteAction() error {
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()),
- strings.TrimSpace(u.remoteUsername.Text()),
- u.remotePassword.Text(),
- u.rememberRemoteAuth.Value,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
@@ -1116,18 +1204,67 @@ func (u *ui) startOpenRemoteAction() {
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,
- strings.TrimSpace(u.remoteUsername.Text()),
- u.remotePassword.Text(),
- u.rememberRemoteAuth.Value,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
@@ -1235,11 +1372,32 @@ func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
+ 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.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() {
@@ -1345,17 +1503,48 @@ func (u *ui) startChooseSyncLocalSourceAction() {
})
}
+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: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
+ BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
- if err := u.state.SynchronizeToRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
+ 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 == "" {
@@ -1371,6 +1560,48 @@ func (u *ui) advancedSyncToAction() error {
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 != "" {
@@ -1379,6 +1610,86 @@ func (u *ui) saveAsTargetPath() string {
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))
+}
+
func (u *ui) noteRecentVault(path string) {
path = strings.TrimSpace(path)
if path == "" {
@@ -1489,9 +1800,20 @@ func (u *ui) loadRecentRemotes() {
for _, record := range records {
record.BaseURL = strings.TrimSpace(record.BaseURL)
record.Path = strings.TrimSpace(record.Path)
+ record.LocalVaultPath = strings.TrimSpace(record.LocalVaultPath)
+ record.RemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
+ record.CredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
+ record.SyncMode = strings.TrimSpace(record.SyncMode)
+ record.Username = strings.TrimSpace(record.Username)
+ record.Password = strings.TrimSpace(record.Password)
if record.BaseURL == "" || record.Path == "" {
continue
}
+ if record.Username != "" || record.Password != "" {
+ record.NeedsMigration = true
+ record.Username = ""
+ record.Password = ""
+ }
key := record.BaseURL + "|" + record.Path
if seen[key] {
continue
@@ -1509,6 +1831,15 @@ func (u *ui) loadRecentRemotes() {
}
}
+func (u *ui) hasLegacyRecentRemoteCredentialMigration() bool {
+ for _, record := range u.recentRemotes {
+ if record.NeedsMigration {
+ return true
+ }
+ }
+ return false
+}
+
func (u *ui) saveRecentVaults() {
if strings.TrimSpace(u.recentVaultsPath) == "" {
return
@@ -1709,7 +2040,7 @@ func (u *ui) setAutofillNoticePreference(value autofillNoticeMode) {
u.saveUIPreferences()
}
-func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) {
+func (u *ui) noteRecentRemote(baseURL, path string) {
baseURL = strings.TrimSpace(baseURL)
path = strings.TrimSpace(path)
if baseURL == "" || path == "" {
@@ -1721,13 +2052,15 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember
LastGroup: append([]string(nil), u.currentPath...),
UsedAt: u.now().Format(time.RFC3339Nano),
}
+ if binding, ok := u.selectedVaultRemoteBinding(); ok {
+ record.LocalVaultPath = binding.LocalVaultPath
+ record.RemoteProfileID = binding.RemoteProfileID
+ record.CredentialEntryID = binding.CredentialEntryID
+ record.SyncMode = string(binding.SyncMode)
+ }
if len(record.LastGroup) == 0 {
record.LastGroup = u.recentRemoteGroup(baseURL, path)
}
- if rememberAuth {
- record.Username = strings.TrimSpace(username)
- record.Password = password
- }
next := []recentRemoteRecord{record}
for _, existing := range u.recentRemotes {
if existing.BaseURL == baseURL && existing.Path == path {
@@ -1761,12 +2094,15 @@ func (u *ui) restoreStartupLifecycleTarget() {
remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote()
switch {
- case hasRemote && (localPath == "" || remoteUsedAt.After(localUsedAt)):
- u.lifecycleMode = "remote"
- u.applyRecentRemoteRecord(remoteRecord)
+ case hasRemote && strings.TrimSpace(remoteRecord.LocalVaultPath) != "" && (localPath == "" || remoteUsedAt.After(localUsedAt)):
+ u.lifecycleMode = "local"
+ u.vaultPath.SetText(strings.TrimSpace(remoteRecord.LocalVaultPath))
case localPath != "":
u.lifecycleMode = "local"
u.vaultPath.SetText(localPath)
+ case hasRemote:
+ u.lifecycleMode = "remote"
+ u.applyRecentRemoteRecord(remoteRecord)
}
}
@@ -1830,7 +2166,6 @@ func (u *ui) switchToLifecycleSelection(mode string) {
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
- u.rememberRemoteAuth.Value = false
u.selectedRemoteConnection = false
default:
u.vaultPath.SetText("")
@@ -1838,7 +2173,6 @@ func (u *ui) switchToLifecycleSelection(mode string) {
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
- u.rememberRemoteAuth.Value = false
u.selectedRemoteConnection = false
}
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
@@ -1861,10 +2195,8 @@ func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
func (u *ui) currentRemoteRecord() recentRemoteRecord {
return recentRemoteRecord{
- BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
- Path: strings.TrimSpace(u.remotePath.Text()),
- Username: strings.TrimSpace(u.remoteUsername.Text()),
- Password: u.remotePassword.Text(),
+ BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
+ Path: strings.TrimSpace(u.remotePath.Text()),
}
}
@@ -1884,33 +2216,651 @@ func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) {
func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.remoteBaseURL.SetText(record.BaseURL)
u.remotePath.SetText(record.Path)
- u.remoteUsername.SetText(record.Username)
- u.remotePassword.SetText(record.Password)
+ u.vaultPath.SetText(strings.TrimSpace(record.LocalVaultPath))
+ u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
+ u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
+ u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
u.remotePassword.Mask = '•'
- u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
u.selectedRemoteConnection = true
+ if record.NeedsMigration && strings.TrimSpace(record.RemoteProfileID) == "" && strings.TrimSpace(record.CredentialEntryID) == "" {
+ u.showStatusMessage("This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it.")
+ }
}
func (u *ui) remotePreferencesCurrentSummary() string {
- selected, hasSelected := u.selectedRecentRemoteRecord()
switch {
- case !u.rememberRemoteAuth.Value:
- return "Current choice: KeePassGO will remember only the WebDAV location for this connection."
- case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""):
- return "Current choice: a successful open will update the saved sign-in for this connection on this device."
case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "":
- return "Current choice: a successful open will save the entered sign-in for this connection on this device."
+ return "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile."
default:
- return "Current choice: sign-in retention is enabled, but no username or password is entered yet."
+ return "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state."
}
}
func (u *ui) remotePreferencesAlwaysSavedSummary() string {
- return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection."
+ return "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection."
}
func (u *ui) remotePreferencesRetentionSummary() string {
- return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password."
+ return "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls."
+}
+
+func (u *ui) remotePreferencesPersistenceSummary() string {
+ return "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself."
+}
+
+func (u *ui) availableRemoteProfiles() []vault.RemoteProfile {
+ profiles, err := u.state.RemoteProfiles()
+ if err != nil {
+ return nil
+ }
+ return profiles
+}
+
+func (u *ui) availableRemoteCredentialEntries() []vault.Entry {
+ entries, err := u.state.RemoteCredentialEntries()
+ if err != nil {
+ return nil
+ }
+ return entries
+}
+
+func normalizeRemoteCredentialURL(raw string) string {
+ raw = strings.TrimSpace(raw)
+ raw = strings.TrimRight(raw, "/")
+ return raw
+}
+
+func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
+ if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote {
+ return nil
+ }
+ baseURL := normalizeRemoteCredentialURL(u.syncRemoteBaseURL.Text())
+ if baseURL == "" {
+ return nil
+ }
+ remotePath := strings.TrimSpace(u.syncRemotePath.Text())
+ entries := u.availableRemoteCredentialEntries()
+ byID := make(map[string]vault.Entry, len(entries))
+ for _, entry := range entries {
+ byID[entry.ID] = entry
+ }
+ matches := make([]vault.Entry, 0, len(entries))
+ seen := make(map[string]struct{}, len(entries))
+ appendMatch := func(entry vault.Entry) {
+ if strings.TrimSpace(entry.ID) == "" {
+ return
+ }
+ if _, ok := seen[entry.ID]; ok {
+ return
+ }
+ seen[entry.ID] = struct{}{}
+ matches = append(matches, entry)
+ }
+ for _, entry := range entries {
+ if normalizeRemoteCredentialURL(entry.URL) != baseURL {
+ continue
+ }
+ appendMatch(entry)
+ }
+ profilesByID := make(map[string]vault.RemoteProfile)
+ for _, profile := range u.availableRemoteProfiles() {
+ profilesByID[profile.ID] = profile
+ }
+ localVaultPath := strings.TrimSpace(u.vaultPath.Text())
+ for _, record := range u.recentRemotes {
+ if localVaultPath != "" && strings.TrimSpace(record.LocalVaultPath) != localVaultPath {
+ continue
+ }
+ profile, ok := profilesByID[strings.TrimSpace(record.RemoteProfileID)]
+ if !ok {
+ continue
+ }
+ if normalizeRemoteCredentialURL(profile.BaseURL) != baseURL {
+ continue
+ }
+ if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath {
+ continue
+ }
+ entry, ok := byID[strings.TrimSpace(record.CredentialEntryID)]
+ if !ok {
+ continue
+ }
+ appendMatch(entry)
+ }
+ return matches
+}
+
+func (u *ui) applyAdvancedSyncRemoteCredentialEntry(entry vault.Entry) {
+ u.selectedSyncRemoteCredentialEntryID = strings.TrimSpace(entry.ID)
+ u.syncRemoteUsername.SetText(strings.TrimSpace(entry.Username))
+ u.syncRemotePassword.SetText(entry.Password)
+}
+
+func (u *ui) savedAdvancedSyncRemoteBinding() (appstate.ResolvedRemoteBinding, bool) {
+ if !u.hasOpenVault() {
+ return appstate.ResolvedRemoteBinding{}, false
+ }
+ _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
+ if err != nil || !ok {
+ return appstate.ResolvedRemoteBinding{}, false
+ }
+ return resolved, true
+}
+
+func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() {
+ resolved, ok := u.savedAdvancedSyncRemoteBinding()
+ if !ok {
+ return
+ }
+ u.syncRemoteBaseURL.SetText(resolved.Profile.BaseURL)
+ u.syncRemotePath.SetText(resolved.Profile.Path)
+ u.applyAdvancedSyncRemoteCredentialEntry(resolved.Credentials)
+}
+
+func (u *ui) syncDialogTitle() string {
+ if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
+ if _, ok := u.selectedVaultRemoteBinding(); ok {
+ return "Remote Sync Settings"
+ }
+ return "Set Up Remote Sync"
+ }
+ return "Advanced Sync"
+}
+
+func (u *ui) syncDialogDescription() string {
+ if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
+ if _, ok := u.selectedVaultRemoteBinding(); ok {
+ return "Review or change this vault's saved WebDAV target, credentials, and sync mode."
+ }
+ return "Send this local vault to a WebDAV target, then use that target for future sync."
+ }
+ return "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings."
+}
+
+func (u *ui) syncDialogConfirmButtonLabel() string {
+ if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
+ if _, ok := u.selectedVaultRemoteBinding(); ok {
+ return "Save Remote Sync Settings"
+ }
+ return "Set Up Remote Sync"
+ }
+ return "Synchronize"
+}
+
+func (u *ui) shouldShowSyncDirectionChoices() bool {
+ return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
+}
+
+func (u *ui) shouldShowSyncSourceChoices() bool {
+ return u.syncDialogPurpose != syncDialogPurposeRemoteSetup
+}
+
+func (u *ui) syncSetupMode() appstate.SyncMode {
+ if u.syncSetupAutomatic.Value {
+ return appstate.SyncModeAutomaticOnOpenSave
+ }
+ return appstate.SyncModeManual
+}
+
+func (u *ui) selectVaultRemoteProfile(id string) {
+ id = strings.TrimSpace(id)
+ u.selectedVaultRemoteProfileID = id
+ for _, profile := range u.availableRemoteProfiles() {
+ if profile.ID != id {
+ continue
+ }
+ u.remoteBaseURL.SetText(profile.BaseURL)
+ u.remotePath.SetText(profile.Path)
+ return
+ }
+}
+
+func (u *ui) selectVaultRemoteCredentialEntry(id string) {
+ u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(id)
+}
+
+func (u *ui) selectedVaultRemoteProfile() (vault.RemoteProfile, bool) {
+ selectedID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
+ profiles := u.availableRemoteProfiles()
+ for _, profile := range profiles {
+ if profile.ID == selectedID {
+ return profile, true
+ }
+ }
+ if selectedID == "" && len(profiles) == 1 {
+ return profiles[0], true
+ }
+ return vault.RemoteProfile{}, false
+}
+
+func (u *ui) selectedVaultRemoteCredentialEntry() (vault.Entry, bool) {
+ selectedID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
+ entries := u.availableRemoteCredentialEntries()
+ for _, entry := range entries {
+ if entry.ID == selectedID {
+ return entry, true
+ }
+ }
+ if selectedID == "" && len(entries) == 1 {
+ return entries[0], true
+ }
+ return vault.Entry{}, false
+}
+
+func (u *ui) selectedVaultRemoteBinding() (appstate.RemoteBinding, bool) {
+ profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
+ entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
+ if profileID != "" && entryID != "" {
+ return appstate.RemoteBinding{
+ LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
+ RemoteProfileID: profileID,
+ CredentialEntryID: entryID,
+ SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
+ }, true
+ }
+ profile, ok := u.selectedVaultRemoteProfile()
+ if !ok {
+ return appstate.RemoteBinding{}, false
+ }
+ entry, ok := u.selectedVaultRemoteCredentialEntry()
+ if !ok {
+ return appstate.RemoteBinding{}, false
+ }
+ return appstate.RemoteBinding{
+ LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
+ RemoteProfileID: profile.ID,
+ CredentialEntryID: entry.ID,
+ SyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
+ }, true
+}
+
+func normalizeUISyncMode(mode appstate.SyncMode) appstate.SyncMode {
+ switch mode {
+ case appstate.SyncModeAutomaticOnOpenSave:
+ return appstate.SyncModeAutomaticOnOpenSave
+ default:
+ return appstate.SyncModeManual
+ }
+}
+
+func (u *ui) newRemoteBindingSyncMode() appstate.SyncMode {
+ if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
+ return appstate.SyncModeAutomaticOnOpenSave
+ }
+ if u.selectedVaultRemoteSyncMode == "" {
+ return appstate.SyncModeAutomaticOnOpenSave
+ }
+ return appstate.SyncModeManual
+}
+
+func (u *ui) syncSavedRemoteBindingSelection() {
+ profiles := u.availableRemoteProfiles()
+ entries := u.availableRemoteCredentialEntries()
+
+ profileID := strings.TrimSpace(u.selectedVaultRemoteProfileID)
+ if profileID != "" {
+ var found bool
+ for _, profile := range profiles {
+ if profile.ID == profileID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ u.selectedVaultRemoteProfileID = ""
+ }
+ }
+ if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" && len(profiles) == 1 {
+ u.selectedVaultRemoteProfileID = profiles[0].ID
+ }
+ if profile, ok := u.selectedVaultRemoteProfile(); ok {
+ u.remoteBaseURL.SetText(profile.BaseURL)
+ u.remotePath.SetText(profile.Path)
+ }
+
+ entryID := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID)
+ if entryID != "" {
+ var found bool
+ for _, entry := range entries {
+ if entry.ID == entryID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ u.selectedVaultRemoteCredentialEntryID = ""
+ }
+ }
+ if strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" && len(entries) == 1 {
+ u.selectedVaultRemoteCredentialEntryID = entries[0].ID
+ }
+ if strings.TrimSpace(u.selectedVaultRemoteProfileID) == "" || strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == "" {
+ if record, ok := u.boundRecentRemoteForLocalVault(strings.TrimSpace(u.vaultPath.Text())); ok {
+ u.selectedVaultRemoteProfileID = strings.TrimSpace(record.RemoteProfileID)
+ u.selectedVaultRemoteCredentialEntryID = strings.TrimSpace(record.CredentialEntryID)
+ u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
+ if profile, ok := u.selectedVaultRemoteProfile(); ok {
+ u.remoteBaseURL.SetText(profile.BaseURL)
+ u.remotePath.SetText(profile.Path)
+ }
+ }
+ }
+ if binding, ok := u.selectedVaultRemoteBinding(); ok {
+ for _, record := range u.recentRemotes {
+ if strings.TrimSpace(record.LocalVaultPath) != strings.TrimSpace(binding.LocalVaultPath) {
+ continue
+ }
+ if strings.TrimSpace(record.RemoteProfileID) != strings.TrimSpace(binding.RemoteProfileID) {
+ continue
+ }
+ if strings.TrimSpace(record.CredentialEntryID) != strings.TrimSpace(binding.CredentialEntryID) {
+ continue
+ }
+ u.selectedVaultRemoteSyncMode = normalizeUISyncMode(appstate.SyncMode(record.SyncMode))
+ return
+ }
+ }
+ u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
+}
+
+func (u *ui) boundRecentRemoteForLocalVault(path string) (recentRemoteRecord, bool) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return recentRemoteRecord{}, false
+ }
+ var matches []recentRemoteRecord
+ for _, record := range u.recentRemotes {
+ if strings.TrimSpace(record.LocalVaultPath) != path {
+ continue
+ }
+ if strings.TrimSpace(record.RemoteProfileID) == "" || strings.TrimSpace(record.CredentialEntryID) == "" {
+ continue
+ }
+ matches = append(matches, record)
+ }
+ if len(matches) != 1 {
+ return recentRemoteRecord{}, false
+ }
+ return matches[0], true
+}
+
+func (u *ui) shouldShowSavedRemoteBindingSelectors() bool {
+ profiles := u.availableRemoteProfiles()
+ entries := u.availableRemoteCredentialEntries()
+ if len(profiles) == 0 || len(entries) == 0 {
+ return false
+ }
+ return len(profiles) > 1 || len(entries) > 1
+}
+
+func (u *ui) savedRemoteBindingSummary() (profileLabel, credentialLabel, syncLabel string, ok bool) {
+ profile, ok := u.selectedVaultRemoteProfile()
+ if !ok {
+ return "", "", "", false
+ }
+ entry, ok := u.selectedVaultRemoteCredentialEntry()
+ if !ok {
+ return "", "", "", false
+ }
+ 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 profile.Name, credentialLabel, syncLabel, true
+}
+
+func (u *ui) savedRemoteBindingHeading() string {
+ if !u.shouldShowSavedRemoteBindingSelectors() {
+ return "Use this vault's saved remote sync target"
+ }
+ return "Use a saved remote profile from this vault"
+}
+
+func (u *ui) openSelectedVaultRemoteButtonLabel() string {
+ if !u.shouldShowSavedRemoteBindingSelectors() {
+ return "Use Remote Sync"
+ }
+ return "Open Saved Remote"
+}
+
+func (u *ui) shouldShowDirectRemoteSyncShortcut() bool {
+ if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
+ return false
+ }
+ _, ok := u.selectedVaultRemoteBinding()
+ return ok
+}
+
+func (u *ui) directRemoteSyncShortcutLabel() string {
+ return "Use Remote Sync"
+}
+
+func (u *ui) shouldShowRemoteSyncSettingsShortcut() bool {
+ if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
+ return false
+ }
+ _, ok := u.selectedVaultRemoteBinding()
+ return ok
+}
+
+func (u *ui) remoteSyncSettingsShortcutLabel() string {
+ return "Remote Sync Settings"
+}
+
+func (u *ui) shouldShowRemoveRemoteSyncShortcut() bool {
+ return u.shouldShowRemoteSyncSettingsShortcut()
+}
+
+func (u *ui) removeRemoteSyncShortcutLabel() string {
+ return "Stop Using Remote Sync"
+}
+
+func (u *ui) shouldShowRemoteSyncSetupShortcut() bool {
+ if !u.hasOpenVault() || u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
+ return false
+ }
+ _, ok := u.selectedVaultRemoteBinding()
+ return !ok
+}
+
+func (u *ui) remoteSyncSetupShortcutLabel() string {
+ return "Set Up Remote Sync"
+}
+
+func remoteBindingSuffix(baseURL, path, username string) string {
+ sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username)))
+ return hex.EncodeToString(sum[:8])
+}
+
+func (u *ui) currentRemoteBindingInput() (appstate.RemoteBindingInput, error) {
+ baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
+ remotePath := strings.TrimSpace(u.remotePath.Text())
+ username := strings.TrimSpace(u.remoteUsername.Text())
+ password := u.remotePassword.Text()
+ localVaultPath := strings.TrimSpace(u.vaultPath.Text())
+
+ switch {
+ case localVaultPath == "":
+ return appstate.RemoteBindingInput{}, fmt.Errorf("local vault path is required")
+ case baseURL == "":
+ return appstate.RemoteBindingInput{}, fmt.Errorf("remote base URL is required")
+ case remotePath == "":
+ return appstate.RemoteBindingInput{}, fmt.Errorf("remote path is required")
+ case username == "":
+ return appstate.RemoteBindingInput{}, fmt.Errorf("remote username is required")
+ case password == "":
+ return appstate.RemoteBindingInput{}, fmt.Errorf("remote password is required")
+ }
+
+ suffix := remoteBindingSuffix(baseURL, remotePath, username)
+ credentialTitle := "WebDAV Sign-In"
+ if username != "" {
+ credentialTitle += " · " + username
+ }
+
+ return appstate.RemoteBindingInput{
+ LocalVaultPath: localVaultPath,
+ RemoteProfileID: "remote-profile-" + suffix,
+ RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
+ BaseURL: baseURL,
+ RemotePath: remotePath,
+ CredentialEntryID: "remote-credential-" + suffix,
+ CredentialTitle: credentialTitle,
+ Username: username,
+ Password: password,
+ CredentialPath: append([]string(nil), u.currentPath...),
+ SyncMode: u.newRemoteBindingSyncMode(),
+ }, nil
+}
+
+func (u *ui) saveCurrentRemoteBindingAction() error {
+ input, err := u.currentRemoteBindingInput()
+ if err != nil {
+ return err
+ }
+ binding, err := u.state.ConfigureRemoteBinding(input)
+ if err != nil {
+ return err
+ }
+ u.selectedVaultRemoteProfileID = binding.RemoteProfileID
+ u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
+ u.selectedVaultRemoteSyncMode = binding.SyncMode
+ return nil
+}
+
+func (u *ui) stripRecentRemoteBinding(binding appstate.RemoteBinding) {
+ localPath := strings.TrimSpace(binding.LocalVaultPath)
+ profileID := strings.TrimSpace(binding.RemoteProfileID)
+ credentialID := strings.TrimSpace(binding.CredentialEntryID)
+ for i := range u.recentRemotes {
+ record := &u.recentRemotes[i]
+ if strings.TrimSpace(record.LocalVaultPath) != localPath {
+ continue
+ }
+ if strings.TrimSpace(record.RemoteProfileID) != profileID {
+ continue
+ }
+ if strings.TrimSpace(record.CredentialEntryID) != credentialID {
+ continue
+ }
+ record.LocalVaultPath = ""
+ record.RemoteProfileID = ""
+ record.CredentialEntryID = ""
+ record.SyncMode = ""
+ }
+}
+
+func (u *ui) removeSelectedRemoteBindingAction() error {
+ binding, ok := u.selectedVaultRemoteBinding()
+ if !ok {
+ return fmt.Errorf("no saved remote sync target is selected")
+ }
+ if err := u.state.RemoveRemoteBinding(binding); err != nil {
+ return err
+ }
+ if err := u.state.Save(); err != nil {
+ return err
+ }
+ u.stripRecentRemoteBinding(binding)
+ u.selectedVaultRemoteProfileID = ""
+ u.selectedVaultRemoteCredentialEntryID = ""
+ u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
+ u.remoteUsername.SetText("")
+ u.remotePassword.SetText("")
+ u.showStatusMessage("Remote sync is no longer set up for this vault.")
+ return nil
+}
+
+func (u *ui) saveCurrentRemoteBindingHeading() string {
+ return "Bind this local vault to the current remote target"
+}
+
+func (u *ui) saveCurrentRemoteBindingButtonLabel() string {
+ return "Save Remote In Vault"
+}
+
+func (u *ui) materializeCurrentRemoteCache() error {
+ cachePath := strings.TrimSpace(u.vaultPath.Text())
+ if cachePath == "" {
+ cachePath = u.saveAsTargetPath()
+ }
+ if cachePath == "" {
+ return nil
+ }
+ u.vaultPath.SetText(cachePath)
+ if err := u.state.SaveAs(cachePath); err != nil {
+ return err
+ }
+ u.noteRecentVault(cachePath)
+
+ username := strings.TrimSpace(u.remoteUsername.Text())
+ password := u.remotePassword.Text()
+ if username == "" && password == "" {
+ return nil
+ }
+
+ input, err := u.currentRemoteBindingInput()
+ if err != nil {
+ return err
+ }
+ binding, err := u.state.ConfigureRemoteBinding(input)
+ if err != nil {
+ return err
+ }
+ if err := u.state.SaveAs(cachePath); err != nil {
+ return err
+ }
+ u.selectedVaultRemoteProfileID = binding.RemoteProfileID
+ u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
+ u.selectedVaultRemoteSyncMode = binding.SyncMode
+ return nil
+}
+
+func (u *ui) bootstrapSelectedVaultRemoteBinding(key vault.MasterKey) (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
+ if u.hasOpenVault() {
+ return u.resolvedSelectedVaultRemoteBinding()
+ }
+
+ binding, ok := u.selectedVaultRemoteBinding()
+ if !ok || strings.TrimSpace(binding.LocalVaultPath) == "" {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
+ }
+ if err := u.state.OpenVault(binding.LocalVaultPath, key); err != nil {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+ }
+ u.vaultPath.SetText(binding.LocalVaultPath)
+ u.noteRecentVault(binding.LocalVaultPath)
+ u.restoreRecentVaultGroup(binding.LocalVaultPath)
+
+ model, err := u.state.Session.Current()
+ if err != nil {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+ }
+ resolved, err := binding.Resolve(model)
+ if err != nil {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+ }
+ return binding, resolved, true, nil
+}
+
+func (u *ui) resolvedSelectedVaultRemoteBinding() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
+ binding, ok := u.selectedVaultRemoteBinding()
+ if !ok {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
+ }
+ model, err := u.state.Session.Current()
+ if err != nil {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+ }
+ resolved, err := binding.Resolve(model)
+ if err != nil {
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+ }
+ return binding, resolved, true, nil
}
func (u *ui) noteCurrentRemotePath() {
@@ -2333,17 +3283,141 @@ func (u *ui) remoteOpenRetryAvailable() bool {
return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:")
}
+func (u *ui) selectedRemoteUsesLocalCache() bool {
+ return u.hasSelectedRemoteTarget() &&
+ strings.TrimSpace(u.vaultPath.Text()) != "" &&
+ strings.TrimSpace(u.selectedVaultRemoteProfileID) != "" &&
+ strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) != ""
+}
+
+func (u *ui) currentSessionIsRemote() bool {
+ session, ok := u.state.Session.(interface{ IsRemote() bool })
+ return ok && session.IsRemote()
+}
+
+func (u *ui) resolvedSelectedVaultRemoteBindingForAutoSync() (appstate.RemoteBinding, appstate.ResolvedRemoteBinding, bool, error) {
+ binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding()
+ if err == nil || !ok {
+ return binding, resolved, ok, err
+ }
+ message := err.Error()
+ if strings.Contains(message, "resolve remote profile:") || strings.Contains(message, "resolve remote credentials:") {
+ u.selectedVaultRemoteProfileID = ""
+ u.selectedVaultRemoteCredentialEntryID = ""
+ u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, nil
+ }
+ return appstate.RemoteBinding{}, appstate.ResolvedRemoteBinding{}, false, err
+}
+
+func (u *ui) synchronizeSelectedRemoteBindingOnOpen() error {
+ if u.currentSessionIsRemote() {
+ return nil
+ }
+ binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
+ if err != nil || !ok {
+ return err
+ }
+ if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
+ return nil
+ }
+ client := webdav.Client{
+ BaseURL: resolved.Profile.BaseURL,
+ Username: resolved.Credentials.Username,
+ Password: resolved.Credentials.Password,
+ }
+ if err := u.state.SynchronizeFromRemote(client, resolved.Profile.Path); err != nil {
+ return err
+ }
+ if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
+ return err
+ }
+ u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
+ u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
+ return nil
+}
+
+func (u *ui) synchronizeSelectedRemoteBindingOnSave() error {
+ if u.currentSessionIsRemote() {
+ return nil
+ }
+ binding, resolved, ok, err := u.resolvedSelectedVaultRemoteBindingForAutoSync()
+ if err != nil || !ok {
+ return err
+ }
+ if binding.SyncMode != appstate.SyncModeAutomaticOnOpenSave {
+ return nil
+ }
+ client := webdav.Client{
+ BaseURL: resolved.Profile.BaseURL,
+ Username: resolved.Credentials.Username,
+ Password: resolved.Credentials.Password,
+ }
+ if err := u.state.SynchronizeToRemote(client, resolved.Profile.Path); err != nil {
+ return err
+ }
+ if err := u.reapplyResolvedRemoteBinding(binding, resolved); err != nil {
+ return err
+ }
+ if err := u.state.Save(); err != nil {
+ return err
+ }
+ u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
+ return nil
+}
+
+func (u *ui) reapplyResolvedRemoteBinding(binding appstate.RemoteBinding, resolved appstate.ResolvedRemoteBinding) error {
+ _, err := u.state.ConfigureRemoteBinding(appstate.RemoteBindingInput{
+ LocalVaultPath: binding.LocalVaultPath,
+ RemoteProfileID: resolved.Profile.ID,
+ RemoteProfileName: resolved.Profile.Name,
+ BaseURL: resolved.Profile.BaseURL,
+ RemotePath: resolved.Profile.Path,
+ CredentialEntryID: resolved.Credentials.ID,
+ CredentialTitle: resolved.Credentials.Title,
+ Username: resolved.Credentials.Username,
+ Password: resolved.Credentials.Password,
+ CredentialPath: append([]string(nil), resolved.Credentials.Path...),
+ SyncMode: binding.SyncMode,
+ })
+ if err != nil {
+ return err
+ }
+ u.selectedVaultRemoteSyncMode = binding.SyncMode
+ return nil
+}
+
+func (u *ui) remoteLifecycleMessage() string {
+ if u.selectedRemoteUsesLocalCache() {
+ return "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings."
+ }
+ return "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly."
+}
+
func (u *ui) remoteOpenButtonLabel() string {
switch {
case u.lifecycleBusy():
- return "Opening Remote Vault..."
+ if u.selectedRemoteUsesLocalCache() {
+ return "Opening Cached Vault..."
+ }
+ return "Creating Local Cache..."
case u.remoteOpenRetryAvailable():
- return "Retry Remote Vault"
+ if u.selectedRemoteUsesLocalCache() {
+ return "Retry Cached Vault"
+ }
+ return "Retry Local Cache Setup"
default:
- return "Open Remote Vault"
+ if u.selectedRemoteUsesLocalCache() {
+ return "Open Cached Vault"
+ }
+ return "Create Local Cache"
}
}
+func (u *ui) remoteLifecycleSetupSummary() string {
+ return "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target."
+}
+
func (u *ui) bannerSurface() uiBanner {
switch {
case strings.TrimSpace(u.loadingMessage) != "":
@@ -3198,6 +4272,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
}
+ for u.importSharedVault.Clicked(gtx) {
+ if u.lifecycleBusy() {
+ continue
+ }
+ u.startImportSharedVaultAction()
+ }
for u.pickKeyFile.Clicked(gtx) {
if u.lifecycleBusy() {
continue
@@ -3231,6 +4311,48 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
}
}
+ for i := range u.vaultRemoteProfileClicks {
+ for u.vaultRemoteProfileClicks[i].Clicked(gtx) {
+ profiles := u.availableRemoteProfiles()
+ if i < len(profiles) {
+ u.selectVaultRemoteProfile(profiles[i].ID)
+ }
+ }
+ }
+ for i := range u.vaultRemoteCredentialClicks {
+ for u.vaultRemoteCredentialClicks[i].Clicked(gtx) {
+ entries := u.availableRemoteCredentialEntries()
+ if i < len(entries) {
+ u.selectVaultRemoteCredentialEntry(entries[i].ID)
+ }
+ }
+ }
+ for i := range u.syncRemoteCredentialClicks {
+ for u.syncRemoteCredentialClicks[i].Clicked(gtx) {
+ entries := u.matchingAdvancedSyncRemoteCredentialEntries()
+ if i < len(entries) {
+ u.applyAdvancedSyncRemoteCredentialEntry(entries[i])
+ }
+ }
+ }
+ for u.useSavedAdvancedSyncRemote.Clicked(gtx) {
+ u.openRemoteSyncSetupDialog()
+ }
+ for u.openSelectedVaultRemote.Clicked(gtx) {
+ if u.lifecycleBusy() {
+ continue
+ }
+ u.startOpenRemoteAction()
+ }
+ for u.saveCurrentRemoteBinding.Clicked(gtx) {
+ u.runAction("save remote binding", u.saveCurrentRemoteBindingAction)
+ }
+ for u.removeSelectedRemoteBinding.Clicked(gtx) {
+ u.runAction("remove remote sync binding", u.removeSelectedRemoteBindingAction)
+ }
+ for u.shareCurrentVault.Clicked(gtx) {
+ u.runAction("share vault", u.shareCurrentVaultAction)
+ }
for u.clearVaultSelection.Clicked(gtx) {
if u.lifecycleBusy() {
continue
@@ -3257,7 +4379,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
- u.rememberRemoteAuth.Value = false
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
@@ -3708,7 +4829,7 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
- return syncDialogSummaryCard(gtx, u.theme, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
+ return syncDialogSummaryCard(gtx, u.theme, syncDialogPurposeAdvanced, u.settingsDraft.Sync.SourceDefault, u.settingsDraft.Sync.DirectionDefault)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
@@ -3875,7 +4996,7 @@ func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions {
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
- return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx)
+ return approvalFact(u.theme, "How Persistence Works", u.remotePreferencesPersistenceSummary(), "")(gtx)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
@@ -3963,55 +5084,83 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
}
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.lifecycleList).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), "Advanced Sync")
+ 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), "Pick direction, choose the other vault, and then run the merge. Saved source and direction defaults now live in Settings.")
+ 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(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,
+ 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 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)
+ 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(layout.Spacer{Height: unit.Dp(12)}.Layout),
- 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,
+ 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 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)
+ 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(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection)
+ 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 {
- return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ 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)),
@@ -4021,6 +5170,50 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
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) {
@@ -4032,7 +5225,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
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, "Synchronize")
+ 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 {
@@ -4340,18 +5533,154 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
+ 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))
+ }
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
- return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ 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),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ if !supportsVaultShare(runtime.GOOS) || u.vaultSharer == nil || strings.TrimSpace(u.currentShareableVaultPath()) == "" {
+ return layout.Dimensions{}
+ }
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
+ }),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
+ )
+ }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
}),
- )
+ }
+ if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 {
+ rows = append(rows,
+ 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), u.savedRemoteBindingHeading())
+ lbl.Color = mutedColor
+ return lbl.Layout(gtx)
+ }),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
+ )
+ if !u.shouldShowSavedRemoteBindingSelectors() {
+ rows = append(rows,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary()
+ if !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), 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: "+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), syncLabel)
+ lbl.Color = mutedColor
+ return lbl.Layout(gtx)
+ }),
+ )
+ })
+ })
+ }),
+ )
+ } else {
+ for i, profile := range profiles {
+ i := i
+ profile := profile
+ rows = append(rows,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ selected := strings.TrimSpace(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 := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == entry.ID
+ label := entry.Title
+ if strings.TrimSpace(entry.Username) != "" {
+ label += " · " + strings.TrimSpace(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)
+ })
+ })
+ })
+ }),
+ )
+ }
+ }
+ 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())
+ }),
+ )
+ }
+ }
+ }
+ if u.hasOpenVault() {
+ baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
+ remotePath := strings.TrimSpace(u.remotePath.Text())
+ username := strings.TrimSpace(u.remoteUsername.Text())
+ password := u.remotePassword.Text()
+ if baseURL != "" && remotePath != "" && username != "" && password != "" {
+ rows = append(rows,
+ 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), u.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, u.saveCurrentRemoteBindingButtonLabel())
+ }),
+ )
+ }
+ }
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
})
}
@@ -5541,56 +6870,95 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
pathSource = append([]string{}, u.currentPath...)
}
crumbs, indices := u.visibleBreadcrumbs(pathSource)
- return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild {
- children := make([]layout.FlexChild, 0, len(crumbs)*2)
- for i, name := range crumbs {
- index := i
- label := name
- children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- for u.breadcrumbs[index].Clicked(gtx) {
- target := indices[index]
- if target == 0 {
- root := u.hiddenVaultRoot()
- if root == "" {
- u.setCurrentPath(nil)
- } else {
- u.setCurrentPath([]string{root})
- }
- } else {
- nextPath := pathSource[:target]
- root := u.hiddenVaultRoot()
- if root != "" {
- nextPath = append([]string{root}, nextPath...)
- }
- u.setCurrentPath(nextPath)
- }
- u.filter()
- }
- btn := material.Button(u.theme, &u.breadcrumbs[index], label)
- btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index)))
- btn.TextSize = unit.Sp(11)
- if u.mode == "phone" {
- btn.TextSize = unit.Sp(9)
- btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6}
- } else {
- btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
- }
- return btn.Layout(gtx)
- }))
- if i < len(crumbs)-1 {
+ crumbBar := func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild {
+ children := make([]layout.FlexChild, 0, len(crumbs)*2)
+ for i, name := range crumbs {
+ index := i
+ label := name
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- lbl := material.Label(u.theme, unit.Sp(12), "/")
- lbl.Color = mutedColor
- inset := unit.Dp(6)
- if u.mode == "phone" {
- inset = unit.Dp(4)
+ for u.breadcrumbs[index].Clicked(gtx) {
+ target := indices[index]
+ if target == 0 {
+ root := u.hiddenVaultRoot()
+ if root == "" {
+ u.setCurrentPath(nil)
+ } else {
+ u.setCurrentPath([]string{root})
+ }
+ } else {
+ nextPath := pathSource[:target]
+ root := u.hiddenVaultRoot()
+ if root != "" {
+ nextPath = append([]string{root}, nextPath...)
+ }
+ u.setCurrentPath(nextPath)
+ }
+ u.filter()
}
- return layout.UniformInset(inset).Layout(gtx, lbl.Layout)
+ btn := material.Button(u.theme, &u.breadcrumbs[index], label)
+ btn.Background, btn.Color = buttonFocusColors(u.accessibilityPrefs, u.isFocused(breadcrumbFocusID(index)))
+ btn.TextSize = unit.Sp(11)
+ if u.mode == "phone" {
+ btn.TextSize = unit.Sp(9)
+ btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6}
+ } else {
+ btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
+ }
+ return btn.Layout(gtx)
}))
+ if i < len(crumbs)-1 {
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ lbl := material.Label(u.theme, unit.Sp(12), "/")
+ lbl.Color = mutedColor
+ inset := unit.Dp(6)
+ if u.mode == "phone" {
+ inset = unit.Dp(4)
+ }
+ return layout.UniformInset(inset).Layout(gtx, lbl.Layout)
+ }))
+ }
}
+ return children
+ }()...)
+ }
+ if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() {
+ return crumbBar(gtx)
+ }
+ children := []layout.FlexChild{
+ layout.Rigid(crumbBar),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
+ }
+ if u.shouldShowDirectRemoteSyncShortcut() {
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel())
+ }))
+ }
+ if u.shouldShowRemoteSyncSetupShortcut() {
+ if u.shouldShowDirectRemoteSyncShortcut() {
+ children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
- return children
- }()...)
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel())
+ }))
+ }
+ if u.shouldShowRemoteSyncSettingsShortcut() {
+ if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() {
+ children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
+ }
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
+ }))
+ }
+ if u.shouldShowRemoveRemoteSyncShortcut() {
+ if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() || u.shouldShowRemoteSyncSettingsShortcut() {
+ children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
+ }
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
+ }))
+ }
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) {
@@ -5921,7 +7289,10 @@ func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget {
}
}
-func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions {
+func syncDialogSummaryText(purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) string {
+ if purpose == syncDialogPurposeRemoteSetup {
+ return "Push this local vault to a WebDAV target and save that target for future sync."
+ }
sourceLabel := "another local vault file"
if source == syncSourceRemote {
sourceLabel = "another WebDAV-backed vault"
@@ -5930,6 +7301,10 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo
if direction == syncDirectionPush {
action = "Push the current vault into"
}
+ return action + " " + sourceLabel + "."
+}
+
+func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncDialogPurpose, source syncSourceMode, direction syncDirection) 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,
@@ -5940,7 +7315,7 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSo
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- lbl := material.Label(th, unit.Sp(14), action+" "+sourceLabel+".")
+ lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction))
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
@@ -6181,5 +7556,19 @@ func runFilePicker(name string, args ...string) (string, error) {
if err != nil {
return "", err
}
- return strings.TrimSpace(string(output)), nil
+ 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")
}
diff --git a/main_test.go b/main_test.go
index f41920d..0c95931 100644
--- a/main_test.go
+++ b/main_test.go
@@ -83,6 +83,49 @@ func (s summarySession) HasVault() bool
func (s summarySession) IsLocked() bool { return s.locked }
func (s summarySession) IsRemote() bool { return s.remote }
+type remoteOpenCaptureSession struct {
+ model vault.Model
+ remoteClient webdav.Client
+ remotePath string
+}
+
+func (s *remoteOpenCaptureSession) Current() (vault.Model, error) {
+ return s.model, nil
+}
+
+func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
+ s.remoteClient = client
+ s.remotePath = path
+ return nil
+}
+
+type saveCaptureSession struct {
+ model vault.Model
+ saveCount int
+ saveErr error
+}
+
+func (s *saveCaptureSession) Current() (vault.Model, error) {
+ return s.model, nil
+}
+
+func (s *saveCaptureSession) Save() error {
+ s.saveCount++
+ return s.saveErr
+}
+
+type captureVaultSharer struct {
+ path string
+ title string
+ err error
+}
+
+func (s *captureVaultSharer) ShareVault(path, title string) error {
+ s.path = path
+ s.title = title
+ return s.err
+}
+
func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
t.Parallel()
@@ -90,7 +133,7 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
- {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
+ {ID: "3", Title: "Surveillance Console", Username: "bashertarr", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
},
})
@@ -128,7 +171,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
u := newUIWithModel(mode, vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
- {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
+ {ID: "entry-2", Title: "Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
},
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
@@ -136,7 +179,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
},
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.invalid", 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"}},
},
})
@@ -144,8 +187,8 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
u.state.NavigateToPath([]string{"Root", "Internet"})
u.search.SetText("climate")
u.filter()
- if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) {
- t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got)
+ if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Vent"}) {
+ t.Fatalf("entries filteredTitles() = %v, want [Vault Vent]", got)
}
u.showTemplatesSection()
@@ -162,11 +205,11 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T)
u.showRecycleBinSection()
u.search.SetText("climate")
u.filter()
- if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) {
- t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got)
+ if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Vault Vent"}) {
+ t.Fatalf("recycle filteredTitles() = %v, want [Deleted Vault Vent]", got)
}
- if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) {
- t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got)
+ if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Safe House"}) {
+ t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Safe House]", got)
}
})
}
@@ -246,7 +289,12 @@ func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) {
func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
started := make(chan struct{})
release := make(chan struct{})
runs := 0
@@ -282,7 +330,12 @@ func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) {
func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
u.vaultPath.SetText("/tmp/example.kdbx")
u.lastLifecycleAction = "open vault"
@@ -327,21 +380,26 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
- {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
+ {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
{ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
})
u.state.NavigateToPath([]string{"Crew"})
- if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
- t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got)
+ if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) {
+ t.Fatalf("childGroups() = %v, want [Internet Security Office]", got)
}
}
func TestUIAPITokenLifecycleManagement(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
@@ -386,7 +444,12 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
@@ -524,7 +587,12 @@ func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) {
func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
@@ -537,7 +605,7 @@ func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) {
t.Fatalf("issueAPITokenAction() error = %v", err)
}
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
- u.apiPolicyPath.SetText("Crew / codex")
+ u.apiPolicyPath.SetText("Crew / bashertarr")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
if err := u.addAPIPolicyRuleAction(); err != nil {
@@ -576,7 +644,7 @@ func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection(
}
firstID := u.state.SelectedEntryID
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
- u.apiPolicyPath.SetText("Crew / codex")
+ u.apiPolicyPath.SetText("Crew / bashertarr")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
if err := u.addAPIPolicyRuleAction(); err != nil {
@@ -1096,7 +1164,7 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(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"},
}); err != nil {
@@ -1175,7 +1243,12 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) {
t.Parallel()
manager := &session.Manager{}
- u := newUIWithSession("desktop", manager)
+ u := newUIWithState("desktop", manager, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
@@ -1247,7 +1320,12 @@ func TestUISaveSettingsPersistsUIPreferences(t *testing.T) {
func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
@@ -1322,7 +1400,7 @@ func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(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"},
}); err != nil {
@@ -1369,7 +1447,13 @@ func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) {
t.Fatalf("WriteFile(updated.key) error = %v", err)
}
- u := newUIWithSession("desktop", &session.Manager{})
+ stateDir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(stateDir, "default.kdbx"),
+ RecentVaultsPath: filepath.Join(stateDir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(stateDir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(stateDir, "ui-prefs.json"),
+ })
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
u.masterPassword.SetText("old-password")
if err := u.createVaultAction(); err != nil {
@@ -1520,7 +1604,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(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"},
},
@@ -1547,10 +1631,17 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) {
}))
defer server.Close()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
+ u.selectedVaultRemoteProfileID = ""
+ u.selectedVaultRemoteCredentialEntryID = ""
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
@@ -1560,7 +1651,7 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-2",
+ Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
@@ -1585,7 +1676,7 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(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"},
}},
@@ -1605,7 +1696,12 @@ func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) {
defer server.Close()
manager := &session.Manager{}
- u := newUIWithSession("desktop", manager)
+ u := newUIWithState("desktop", manager, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
u.masterPassword.SetText(key.Password)
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
@@ -1643,7 +1739,12 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) {
url := server.URL
server.Close()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(url)
u.remotePath.SetText("vaults/main.kdbx")
@@ -1658,6 +1759,783 @@ func TestUIOpenRemoteReportsTransportFailure(t *testing.T) {
}
}
+func TestUIOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ sess := &remoteOpenCaptureSession{
+ 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ },
+ }
+
+ u := newUIWithSession("desktop", sess)
+ u.masterPassword.SetText("correct horse battery staple")
+ u.selectedVaultRemoteProfileID = "family-webdav"
+ u.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
+
+ if err := u.openRemoteAction(); err != nil {
+ t.Fatalf("openRemoteAction() error = %v", err)
+ }
+
+ if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got)
+ }
+ if got := sess.remoteClient.Username; got != "linuscaldwell" {
+ t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got)
+ }
+ if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
+ t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got)
+ }
+ if got := sess.remotePath; got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath editor = %q, want resolved profile path", got)
+ }
+}
+
+func TestUIOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ sess := &remoteOpenCaptureSession{
+ 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ },
+ }
+
+ u := newUIWithSession("desktop", sess)
+ u.masterPassword.SetText("correct horse battery staple")
+ u.vaultPath.SetText("/vaults/family.kdbx")
+
+ if err := u.openRemoteAction(); err != nil {
+ t.Fatalf("openRemoteAction() error = %v", err)
+ }
+
+ if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteClient.BaseURL = %q, want remote.php/dav URL", got)
+ }
+ if got := sess.remoteClient.Username; got != "linuscaldwell" {
+ t.Fatalf("remoteClient.Username = %q, want linuscaldwell", got)
+ }
+ if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
+ t.Fatalf("remoteClient.Password = %q, want bellagio-pass-1", got)
+ }
+ if got := sess.remotePath; got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
+ }
+}
+
+func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ localPath := filepath.Join(t.TempDir(), "family.kdbx")
+
+ remoteModel := vault.Model{
+ Entries: []vault.Entry{{
+ ID: "entry-1",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "remote-token",
+ Path: []string{"Root", "Internet"},
+ }},
+ }
+ var remoteBytes bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ if r.Method != http.MethodGet {
+ t.Fatalf("method = %s, want GET", r.Method)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(remoteBytes.Bytes())
+ }))
+ defer server.Close()
+
+ localModel := vault.Model{}
+ if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{
+ LocalVaultPath: localPath,
+ RemoteProfileID: "family-webdav",
+ RemoteProfileName: "family.kdbx · dav.example.invalid",
+ BaseURL: server.URL,
+ RemotePath: "files/family/keepass.kdbx",
+ CredentialEntryID: "remote-creds-1",
+ CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell",
+ Username: "linuscaldwell",
+ Password: "bellagio-pass-1",
+ CredentialPath: []string{"Crew", "Internet"},
+ SyncMode: appstate.SyncModeAutomaticOnOpenSave,
+ }); err != nil {
+ t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err)
+ }
+ writeKDBXMainTestFile(t, localPath, localModel, key)
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.masterPassword.SetText(key.Password)
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: server.URL,
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: localPath,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ })
+
+ if err := u.openRemoteAction(); err != nil {
+ t.Fatalf("openRemoteAction() error = %v", err)
+ }
+
+ if got := u.vaultPath.Text(); got != localPath {
+ t.Fatalf("vaultPath = %q, want %q", got, localPath)
+ }
+ current, err := u.state.Session.Current()
+ if err != nil {
+ t.Fatalf("Session.Current() error = %v", err)
+ }
+ if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
+ t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
+ }
+}
+
+func TestUIStartOpenRemoteActionUsesSelectedVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ localKey := vault.MasterKey{Password: "correct horse battery staple"}
+ localPath := filepath.Join(t.TempDir(), "family.kdbx")
+ localModel := 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+
+ remoteModel := vault.Model{
+ Entries: []vault.Entry{{
+ ID: "vault-console",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "bellagio-pass-1",
+ URL: "https://vault.crew.example.invalid",
+ Path: []string{"Root", "Internet"},
+ }},
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(encoded.Bytes())
+ }))
+ defer server.Close()
+
+ localModel.RemoteProfiles[0].BaseURL = server.URL
+
+ manager := &session.Manager{}
+ if err := manager.Create(localModel, localKey); err != nil {
+ t.Fatalf("manager.Create() error = %v", err)
+ }
+
+ u := newUIWithSession("desktop", manager)
+ u.masterPassword.SetText(localKey.Password)
+ u.vaultPath.SetText(localPath)
+ u.selectedVaultRemoteProfileID = "family-webdav"
+ u.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
+
+ u.startOpenRemoteAction()
+
+ result := waitForBackgroundResult(t, u)
+ u.applyBackgroundResult(result)
+
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage after apply = %q, want empty", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != server.URL {
+ t.Fatalf("remoteBaseURL = %q, want server URL from selected profile", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want selected profile path", got)
+ }
+}
+
+func TestUIStartOpenRemoteActionUsesImplicitSingleVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ localKey := vault.MasterKey{Password: "correct horse battery staple"}
+ localPath := filepath.Join(t.TempDir(), "family.kdbx")
+ localModel := 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+
+ remoteModel := vault.Model{
+ Entries: []vault.Entry{{
+ ID: "vault-console",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "bellagio-pass-1",
+ URL: "https://vault.crew.example.invalid",
+ Path: []string{"Root", "Internet"},
+ }},
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, remoteModel, localKey); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(encoded.Bytes())
+ }))
+ defer server.Close()
+
+ localModel.RemoteProfiles[0].BaseURL = server.URL
+
+ manager := &session.Manager{}
+ if err := manager.Create(localModel, localKey); err != nil {
+ t.Fatalf("manager.Create() error = %v", err)
+ }
+
+ u := newUIWithSession("desktop", manager)
+ u.masterPassword.SetText(localKey.Password)
+ u.vaultPath.SetText(localPath)
+
+ u.startOpenRemoteAction()
+
+ result := waitForBackgroundResult(t, u)
+ u.applyBackgroundResult(result)
+
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage after apply = %q, want empty", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != server.URL {
+ t.Fatalf("remoteBaseURL = %q, want server URL from implicit profile", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want implicit profile path", got)
+ }
+}
+
+func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ localPath := filepath.Join(t.TempDir(), "family.kdbx")
+
+ remoteModel := vault.Model{
+ Entries: []vault.Entry{{
+ ID: "entry-1",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "remote-token",
+ Path: []string{"Root", "Internet"},
+ }},
+ }
+ var remoteBytes bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&remoteBytes, remoteModel, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ if r.Method != http.MethodGet {
+ t.Fatalf("method = %s, want GET", r.Method)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(remoteBytes.Bytes())
+ }))
+ defer server.Close()
+
+ localModel := vault.Model{}
+ if _, err := appstate.ConfigureRemoteBinding(&localModel, appstate.RemoteBindingInput{
+ LocalVaultPath: localPath,
+ RemoteProfileID: "family-webdav",
+ RemoteProfileName: "family.kdbx · dav.example.invalid",
+ BaseURL: server.URL,
+ RemotePath: "files/family/keepass.kdbx",
+ CredentialEntryID: "remote-creds-1",
+ CredentialTitle: "Bellagio WebDAV Sign-In · linuscaldwell",
+ Username: "linuscaldwell",
+ Password: "bellagio-pass-1",
+ CredentialPath: []string{"Crew", "Internet"},
+ SyncMode: appstate.SyncModeAutomaticOnOpenSave,
+ }); err != nil {
+ t.Fatalf("ConfigureRemoteBinding(localModel) error = %v", err)
+ }
+ writeKDBXMainTestFile(t, localPath, localModel, key)
+
+ manager := &session.Manager{}
+ u := newUIWithSession("desktop", manager)
+ u.masterPassword.SetText(key.Password)
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: server.URL,
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: localPath,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ })
+
+ u.startOpenRemoteAction()
+
+ if got := u.loadingMessage; got != "Open remote vault..." {
+ t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...")
+ }
+
+ result := waitForBackgroundResult(t, u)
+ u.applyBackgroundResult(result)
+
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage after apply = %q, want empty", got)
+ }
+ if got := u.vaultPath.Text(); got != localPath {
+ t.Fatalf("vaultPath = %q, want %q", got, localPath)
+ }
+ current, err := u.state.Session.Current()
+ if err != nil {
+ t.Fatalf("Session.Current() error = %v", err)
+ }
+ if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
+ t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
+ }
+}
+
+func TestUIOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ path := filepath.Join(t.TempDir(), "family.kdbx")
+ 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+ writeKDBXMainTestFile(t, path, model, key)
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: path,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeManual),
+ }}
+ u.vaultPath.SetText(path)
+ u.masterPassword.SetText(key.Password)
+ u.selectedVaultRemoteProfileID = "stale-profile"
+ u.selectedVaultRemoteCredentialEntryID = "stale-credential"
+ u.remoteBaseURL.SetText("https://stale.example.invalid")
+ u.remotePath.SetText("stale/path.kdbx")
+
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+
+ if got := u.selectedVaultRemoteProfileID; got != "family-webdav" {
+ t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got)
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
+ t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want resolved profile path", got)
+ }
+ if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual {
+ t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual from matching recent-remote state", got)
+ }
+}
+
+func TestUIStartOpenVaultActionSelectsSoleSavedRemoteBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ path := filepath.Join(t.TempDir(), "family.kdbx")
+ 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+ writeKDBXMainTestFile(t, path, model, key)
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.vaultPath.SetText(path)
+ u.masterPassword.SetText(key.Password)
+ u.selectedVaultRemoteProfileID = "stale-profile"
+ u.selectedVaultRemoteCredentialEntryID = "stale-credential"
+ u.remoteBaseURL.SetText("https://stale.example.invalid")
+ u.remotePath.SetText("stale/path.kdbx")
+
+ u.startOpenVaultAction()
+
+ result := waitForBackgroundResult(t, u)
+ u.applyBackgroundResult(result)
+
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage after apply = %q, want empty", got)
+ }
+ if got := u.selectedVaultRemoteProfileID; got != "family-webdav" {
+ t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got)
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
+ t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want resolved profile path", got)
+ }
+}
+
+func TestUIOpenVaultActionAutomaticallySynchronizesFromRemoteBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ path := filepath.Join(t.TempDir(), "family.kdbx")
+ localModel := 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://stale.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+ writeKDBXMainTestFile(t, path, localModel, key)
+
+ var remoteBytes bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{
+ Entries: []vault.Entry{{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}},
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ if r.Method != http.MethodGet {
+ t.Fatalf("method = %s, want GET", r.Method)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(remoteBytes.Bytes())
+ }))
+ defer server.Close()
+ localModel.RemoteProfiles[0].BaseURL = server.URL
+ writeKDBXMainTestFile(t, path, localModel, key)
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: server.URL,
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: path,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ }}
+ u.vaultPath.SetText(path)
+ u.masterPassword.SetText(key.Password)
+
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+
+ current, err := u.state.Session.Current()
+ if err != nil {
+ t.Fatalf("Session.Current() error = %v", err)
+ }
+ if _, err := current.EntryByID("vault-console"); err != nil {
+ t.Fatalf("EntryByID(vault-console) error = %v, want remote entry merged on open", err)
+ }
+ if got := u.remoteBaseURL.Text(); got != server.URL {
+ t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL)
+ }
+}
+
+func TestUIOpenVaultActionKeepsLocalVaultOpenWhenAutoSyncFails(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ path := filepath.Join(t.TempDir(), "family.kdbx")
+ localModel := vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Local Cache", Path: []string{"Root", "Internet"}},
+ {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Password: "bellagio-pass-1", Path: []string{"Crew", "Internet"}},
+ },
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://unreachable.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+ writeKDBXMainTestFile(t, path, localModel, key)
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://unreachable.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: path,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ }}
+ u.vaultPath.SetText(path)
+ u.masterPassword.SetText(key.Password)
+
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v, want local open to succeed even if auto-sync fails", err)
+ }
+
+ current, err := u.state.Session.Current()
+ if err != nil {
+ t.Fatalf("Session.Current() error = %v", err)
+ }
+ if _, err := current.EntryByID("entry-1"); err != nil {
+ t.Fatalf("EntryByID(entry-1) error = %v, want local vault opened", err)
+ }
+ if got := u.state.StatusMessage; !strings.Contains(got, "Remote sync on open failed:") {
+ t.Fatalf("StatusMessage = %q, want nonfatal remote sync failure notice", got)
+ }
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage = %q, want empty for nonfatal remote sync failure", got)
+ }
+}
+
+func TestUISaveActionAutomaticallySynchronizesToRemoteBinding(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ path := filepath.Join(t.TempDir(), "family.kdbx")
+ localModel := 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://stale.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }
+ writeKDBXMainTestFile(t, path, localModel, key)
+
+ var (
+ savedRemote []byte
+ putCount int
+ )
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("ETag", "\"v1\"")
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
+ }
+ _, _ = w.Write(encoded.Bytes())
+ case http.MethodPut:
+ putCount++
+ var err error
+ savedRemote, err = io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll(PUT body) error = %v", err)
+ }
+ w.Header().Set("ETag", "\"v2\"")
+ w.WriteHeader(http.StatusCreated)
+ default:
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ }))
+ defer server.Close()
+ localModel.RemoteProfiles[0].BaseURL = server.URL
+ writeKDBXMainTestFile(t, path, localModel, key)
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: server.URL,
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: path,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ }}
+ u.vaultPath.SetText(path)
+ u.masterPassword.SetText(key.Password)
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+ if err := u.state.UpsertEntry(vault.Entry{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}); err != nil {
+ t.Fatalf("UpsertEntry() error = %v", err)
+ }
+
+ if err := u.saveAction(); err != nil {
+ t.Fatalf("saveAction() error = %v", err)
+ }
+
+ if putCount == 0 {
+ t.Fatal("remote PUT count = 0, want automatic remote synchronize on save")
+ }
+ loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedRemote), key)
+ if err != nil {
+ t.Fatalf("LoadKDBXWithKey(savedRemote) error = %v", err)
+ }
+ if _, err := loaded.EntryByID("entry-1"); err != nil {
+ t.Fatalf("EntryByID(entry-1) error = %v, want saved entry on remote", err)
+ }
+}
+
+func TestPickExistingFileOutputExtractsPathFromPortalNoise(t *testing.T) {
+ t.Parallel()
+
+ output := strings.Join([]string{
+ "(zenity:1): Gdk-DEBUG: Ignoring portal setting",
+ "/home/tester/vaults/family.kdbx",
+ "",
+ }, "\n")
+
+ got, err := parsePickedFilePath([]byte(output))
+ if err != nil {
+ t.Fatalf("parsePickedFilePath() error = %v", err)
+ }
+ if got != "/home/tester/vaults/family.kdbx" {
+ t.Fatalf("parsePickedFilePath() = %q, want /home/tester/vaults/family.kdbx", got)
+ }
+}
+
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
t.Parallel()
@@ -1668,7 +2546,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(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"},
},
@@ -1694,10 +2572,17 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
}))
defer server.Close()
- u := newUIWithSession("desktop", &session.Manager{})
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
+ u.selectedVaultRemoteProfileID = ""
+ u.selectedVaultRemoteCredentialEntryID = ""
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
@@ -1706,7 +2591,7 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-2",
+ Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
@@ -2077,7 +2962,7 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
- {ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}},
+ {ID: "entry-2", Title: "Security Office", Path: []string{"Root", "Security Office"}},
},
})
u.showEntriesSection()
@@ -2088,8 +2973,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
if err := u.createGroupAction(); err != nil {
t.Fatalf("createGroupAction() error = %v", err)
}
- if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
- t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got)
+ if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
+ t.Fatalf("childGroups() after create = %v, want [Finance Internet Security Office]", got)
}
u.state.EnterGroup("Finance")
@@ -2104,8 +2989,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
u.state.NavigateToPath([]string{"Root"})
u.filter()
- if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) {
- t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got)
+ if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Internet", "Security Office"}) {
+ t.Fatalf("childGroups() after rename = %v, want [Budget Internet Security Office]", got)
}
u.state.NavigateToPath([]string{"Root", "Budget"})
@@ -2117,8 +3002,8 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
if !slices.Equal(u.state.CurrentPath, []string{"Root"}) {
t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath)
}
- if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
- t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got)
+ if got := u.childGroups(); !slices.Equal(got, []string{"Internet", "Security Office"}) {
+ t.Fatalf("childGroups() after delete = %v, want [Internet Security Office]", got)
}
}
@@ -2182,7 +3067,7 @@ func TestUIParentGroupDoesNotShowDescendantEntries(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"}},
},
})
u.showEntriesSection()
@@ -2251,17 +3136,17 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(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"},
},
{
ID: "ha",
- Title: "Home Assistant",
+ Title: "Security Office",
Username: "rustyryan",
- Password: "token-2",
+ Password: "bellagio-pass-2",
URL: "https://ha.example.test",
- Path: []string{"Root", "Home Assistant"},
+ Path: []string{"Root", "Security Office"},
},
},
})
@@ -2270,7 +3155,7 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
- u.entryPath.SetText("Root / Home Assistant")
+ u.entryPath.SetText("Root / Security Office")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
@@ -2282,10 +3167,10 @@ func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
t.Fatalf("filteredTitles() in source group = %v, want empty after move", got)
}
- u.state.NavigateToPath([]string{"Root", "Home Assistant"})
+ u.state.NavigateToPath([]string{"Root", "Security Office"})
u.filter()
- if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) {
- t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got)
+ if got := u.filteredTitles(); !slices.Equal(got, []string{"Security Office", "Vault Console"}) {
+ t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Security Office]", got)
}
}
@@ -2298,7 +3183,7 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(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"},
},
@@ -2309,14 +3194,14 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
- u.entryPassword.SetText("token-2")
+ u.entryPassword.SetText("bellagio-pass-2")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
u.filter()
- if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" {
- t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry)
+ if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-2" {
+ t.Fatalf("selectedEntry() = %#v, want updated password bellagio-pass-2", entry)
}
if err := u.duplicateSelectedEntryAction(); err != nil {
@@ -2358,7 +3243,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
u.entryID.SetText("bellagio")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
- u.entryPassword.SetText("token-1")
+ u.entryPassword.SetText("bellagio-pass-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryNotes.SetText("Registrar account")
u.entryTags.SetText("dns, registrar")
@@ -2381,7 +3266,7 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
if !ok {
t.Fatal("selectedEntry() ok = false, want created entry")
}
- if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" {
+ if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "bellagio-pass-1" || item.URL != "https://bellagio.example.invalid" {
t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item)
}
if item.Notes != "Registrar account" {
@@ -2439,7 +3324,7 @@ func TestUIEditingEntryPathMovesEntryBetweenGroups(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"},
},
@@ -2522,7 +3407,7 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
- u.entryPassword.SetText("token-1")
+ u.entryPassword.SetText("bellagio-pass-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
@@ -2658,7 +3543,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
- u.entryPassword.SetText("token-1")
+ u.entryPassword.SetText("bellagio-pass-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
@@ -2786,7 +3671,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-2",
+ Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
@@ -2794,7 +3679,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-1",
+ Password: "bellagio-pass-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
@@ -2813,8 +3698,8 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
}
u.filter()
- if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" {
- t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry)
+ if entry, ok := u.selectedEntry(); !ok || entry.Password != "bellagio-pass-1" {
+ t.Fatalf("selectedEntry() = %#v, want restored password bellagio-pass-1", entry)
}
}
@@ -2827,7 +3712,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-2",
+ Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
@@ -2835,7 +3720,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-1",
+ Password: "bellagio-pass-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "previous token",
@@ -2844,7 +3729,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
ID: "vault-console-h0",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-0",
+ Password: "bellagio-pass-0",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "oldest token",
@@ -2875,8 +3760,8 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
if !ok {
t.Fatal("selectedHistoryEntry() ok = false, want true")
}
- if selected.Password != "token-0" {
- t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0")
+ if selected.Password != "bellagio-pass-0" {
+ t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "bellagio-pass-0")
}
}
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
@@ -2888,7 +3773,7 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(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"},
},
@@ -3011,7 +3896,7 @@ func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(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"},
},
@@ -3106,7 +3991,7 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
u = newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
- {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
+ {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "bellagio-pass-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
},
})
u.clipboardWriter = &memoryClipboardWriter{}
@@ -3185,7 +4070,7 @@ func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-1",
+ Password: "bellagio-pass-1",
Path: []string{"Root", "Internet"},
},
},
@@ -3636,7 +4521,7 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
- {ID: "2", Title: "Home Assistant", Path: []string{"keepass", "Crew", "Home"}},
+ {ID: "2", Title: "Security Office", Path: []string{"keepass", "Crew", "Safe House"}},
},
})
@@ -3772,8 +4657,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) {
first.currentPath = []string{"Root", "Internet"}
first.syncedPath = []string{"Root", "Internet"}
first.noteRecentVault("/tmp/one.kdbx")
- first.currentPath = []string{"Root", "Home Assistant"}
- first.syncedPath = []string{"Root", "Home Assistant"}
+ first.currentPath = []string{"Root", "Security Office"}
+ first.syncedPath = []string{"Root", "Security Office"}
first.noteRecentVault("/tmp/two.kdbx")
first.currentPath = []string{"Root", "Finance"}
first.syncedPath = []string{"Root", "Finance"}
@@ -3790,8 +4675,8 @@ func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) {
if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) {
t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got)
}
- if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) {
- t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got)
+ if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Security Office"}) {
+ t.Fatalf("recentVaultGroup(two) = %v, want [Root Security Office]", got)
}
}
@@ -3812,7 +4697,7 @@ func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) {
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-1",
+ Password: "bellagio-pass-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
@@ -3853,11 +4738,11 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) {
first.recentRemotesPath = configPath
first.recentRemotes = nil
first.currentPath = []string{"Root", "Internet"}
- first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
- first.currentPath = []string{"Root", "Home"}
- first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false)
+ first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
+ first.currentPath = []string{"Root", "Safe House"}
+ first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx")
first.currentPath = []string{"Root", "Finance"}
- first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true)
+ first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
second := newUIWithSession("desktop", &session.Manager{})
second.recentRemotesPath = configPath
@@ -3867,17 +4752,164 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) {
if got := len(second.recentRemotes); got != 2 {
t.Fatalf("len(recentRemotes) = %d, want 2", got)
}
- if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" {
- t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got)
+ if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" {
+ t.Fatalf("recentRemotes[0] = %#v, want updated location-only record", got)
}
if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) {
t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got)
}
- if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" {
- t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got)
+ if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Safe House"}) {
+ t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Safe House]", got)
}
- if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Home"}) {
- t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Home]", got)
+}
+
+func TestUIRecentRemoteConnectionsPersistVaultBindingMetadata(t *testing.T) {
+ t.Parallel()
+
+ configPath := filepath.Join(t.TempDir(), "recent-remotes.json")
+
+ first := newUIWithSession("desktop", &session.Manager{})
+ first.recentRemotesPath = configPath
+ first.recentRemotes = nil
+ first.currentPath = []string{"Root", "Internet"}
+ first.vaultPath.SetText("/vaults/family.kdbx")
+ first.selectedVaultRemoteProfileID = "remote-profile-1"
+ first.selectedVaultRemoteCredentialEntryID = "remote-creds-1"
+ first.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave
+ first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
+
+ second := newUIWithSession("desktop", &session.Manager{})
+ second.recentRemotesPath = configPath
+ second.recentRemotes = nil
+ second.loadRecentRemotes()
+
+ if got := len(second.recentRemotes); got != 1 {
+ t.Fatalf("len(recentRemotes) = %d, want 1", got)
+ }
+ record := second.recentRemotes[0]
+ if record.LocalVaultPath != "/vaults/family.kdbx" {
+ t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want /vaults/family.kdbx", record.LocalVaultPath)
+ }
+ if record.RemoteProfileID != "remote-profile-1" {
+ t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want remote-profile-1", record.RemoteProfileID)
+ }
+ if record.CredentialEntryID != "remote-creds-1" {
+ t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want remote-creds-1", record.CredentialEntryID)
+ }
+ if record.SyncMode != string(appstate.SyncModeAutomaticOnOpenSave) {
+ t.Fatalf("recentRemotes[0].SyncMode = %q, want automatic_on_open_save", record.SyncMode)
+ }
+}
+
+func TestUILoadRecentRemotesIgnoresLegacySavedCredentials(t *testing.T) {
+ t.Parallel()
+
+ configPath := filepath.Join(t.TempDir(), "recent-remotes.json")
+ content := `[
+ {
+ "baseUrl": "https://dav.example.com",
+ "path": "vaults/home.kdbx",
+ "username": "debbieocean",
+ "password": "secret-1",
+ "lastGroup": ["Root", "Internet"]
+ }
+]`
+ if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
+ t.Fatalf("WriteFile(recent-remotes.json) error = %v", err)
+ }
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.recentRemotesPath = configPath
+ u.recentRemotes = nil
+ u.loadRecentRemotes()
+
+ if got := len(u.recentRemotes); got != 1 {
+ t.Fatalf("len(recentRemotes) = %d, want 1", got)
+ }
+ if got := u.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" {
+ t.Fatalf("recentRemotes[0] = %#v, want location-only record", got)
+ }
+ if !u.recentRemotes[0].NeedsMigration {
+ t.Fatal("recentRemotes[0].NeedsMigration = false, want true for legacy saved credentials")
+ }
+ if got := u.recentRemotes[0].Username; got != "" {
+ t.Fatalf("recentRemotes[0].Username = %q, want empty after migration strip", got)
+ }
+ if got := u.recentRemotes[0].Password; got != "" {
+ t.Fatalf("recentRemotes[0].Password = %q, want empty after migration strip", got)
+ }
+}
+
+func TestUINewUIShowsMigrationStatusForLegacyRecentRemoteCredentials(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ recentRemotesPath := filepath.Join(dir, "recent-remotes.json")
+ content := `[
+ {
+ "baseUrl": "https://dav.example.com",
+ "path": "vaults/home.kdbx",
+ "username": "debbieocean",
+ "password": "secret-1"
+ }
+]`
+ if err := os.WriteFile(recentRemotesPath, []byte(content), 0o600); err != nil {
+ t.Fatalf("WriteFile(recent-remotes.json) error = %v", err)
+ }
+
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "default.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: recentRemotesPath,
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+
+ if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." {
+ t.Fatalf("StatusMessage = %q, want legacy recent-remote migration notice for the selected startup remote", got)
+ }
+}
+
+func TestUIApplyRecentRemoteRecordRestoresVaultBindingSelection(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.com",
+ Path: "vaults/home.kdbx",
+ LocalVaultPath: "/vaults/family.kdbx",
+ RemoteProfileID: "remote-profile-1",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ })
+
+ if got := u.vaultPath.Text(); got != "/vaults/family.kdbx" {
+ t.Fatalf("vaultPath = %q, want /vaults/family.kdbx", got)
+ }
+ if got := u.selectedVaultRemoteProfileID; got != "remote-profile-1" {
+ t.Fatalf("selectedVaultRemoteProfileID = %q, want remote-profile-1", got)
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
+ t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
+ }
+ if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave {
+ t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got)
+ }
+}
+
+func TestUIApplyRecentRemoteRecordShowsMigrationNoticeForLegacySavedCredentials(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.com",
+ Path: "vaults/home.kdbx",
+ NeedsMigration: true,
+ })
+
+ if got := u.state.StatusMessage; got != "This saved remote came from an older local-sign-in format. Open it again, then save the remote in the vault to migrate it." {
+ t.Fatalf("StatusMessage = %q, want legacy per-record migration notice", got)
}
}
@@ -3898,24 +4930,21 @@ func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) {
first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) }
first.noteRecentVault("/tmp/local.kdbx")
first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) }
- first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
+ first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx")
second := newUIWithSession("desktop", &session.Manager{}, paths)
- if got := second.lifecycleMode; got != "remote" {
- t.Fatalf("lifecycleMode = %q, want remote", got)
+ if got := second.lifecycleMode; got != "local" {
+ t.Fatalf("lifecycleMode = %q, want local", got)
}
- if got := second.remoteBaseURL.Text(); got != "https://dav.example.com" {
- t.Fatalf("remoteBaseURL = %q, want https://dav.example.com", got)
+ if got := second.vaultPath.Text(); got != "/tmp/local.kdbx" {
+ t.Fatalf("vaultPath = %q, want /tmp/local.kdbx", got)
}
- if got := second.remotePath.Text(); got != "vaults/home.kdbx" {
- t.Fatalf("remotePath = %q, want vaults/home.kdbx", got)
+ if got := second.remoteUsername.Text(); got != "" {
+ t.Fatalf("remoteUsername = %q, want empty for location-only recent remote", got)
}
- if got := second.remoteUsername.Text(); got != "alice" {
- t.Fatalf("remoteUsername = %q, want alice", got)
- }
- if got := second.remotePassword.Text(); got != "secret-1" {
- t.Fatalf("remotePassword = %q, want secret-1", got)
+ if got := second.remotePassword.Text(); got != "" {
+ t.Fatalf("remotePassword = %q, want empty for location-only recent remote", got)
}
}
@@ -4311,13 +5340,13 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
u := newUIWithSession("desktop", &session.Manager{})
u.recentRemotes = []recentRemoteRecord{{
- BaseURL: "https://dav.example.com",
- Path: "vaults/home.kdbx",
- Username: "alice",
- Password: "secret-1",
+ BaseURL: "https://dav.example.com",
+ Path: "vaults/home.kdbx",
}}
u.recentRemoteClicks = make([]widget.Clickable, 1)
+ u.remoteUsername.SetText("debbieocean")
+ u.remotePassword.SetText("secret-1")
u.remotePassword.Mask = 0
u.recentRemoteClicks[0].Click()
@@ -4326,15 +5355,18 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
record := u.recentRemotes[0]
u.remoteBaseURL.SetText(record.BaseURL)
u.remotePath.SetText(record.Path)
- u.remoteUsername.SetText(record.Username)
- u.remotePassword.SetText(record.Password)
u.remotePassword.Mask = '•'
- u.rememberRemoteAuth.Value = true
}
if got := u.remotePassword.Mask; got != '•' {
t.Fatalf("remotePassword.Mask = %q, want bullet mask", got)
}
+ if got := u.remoteUsername.Text(); got != "debbieocean" {
+ t.Fatalf("remoteUsername = %q, want preserved manual username", got)
+ }
+ if got := u.remotePassword.Text(); got != "secret-1" {
+ t.Fatalf("remotePassword = %q, want preserved manual password", got)
+ }
}
func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) {
@@ -4387,6 +5419,31 @@ func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T)
}
}
+func TestRestoreStartupLifecycleTargetUsesLocalCacheFromRecentRemote(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+ u.vaultPath.SetText("")
+ u.recentVaults = []string{"/tmp/older.kdbx"}
+ u.recentVaultUsedAt["/tmp/older.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC)
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: "/tmp/family-cache.kdbx",
+ UsedAt: time.Date(2026, time.April, 5, 2, 2, 3, 0, time.UTC).Format(time.RFC3339Nano),
+ }}
+
+ u.restoreStartupLifecycleTarget()
+
+ if got := u.lifecycleMode; got != "local" {
+ t.Fatalf("lifecycleMode after restore = %q, want local", got)
+ }
+ if got := u.vaultPath.Text(); got != "/tmp/family-cache.kdbx" {
+ t.Fatalf("vaultPath after restore = %q, want /tmp/family-cache.kdbx", got)
+ }
+}
+
func TestShowLocalVaultChooser(t *testing.T) {
t.Parallel()
@@ -4411,7 +5468,13 @@ func TestShowLocalVaultChooser(t *testing.T) {
func TestShowRemoteConnectionChooser(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ dir := t.TempDir()
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
u.lifecycleMode = "remote"
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
@@ -4442,16 +5505,20 @@ func TestShowRemoteConnectionChooser(t *testing.T) {
func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) {
t.Parallel()
- u := newUIWithSession("desktop", &session.Manager{})
+ dir := t.TempDir()
+ u := newUIWithState("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
if u.hasSelectedRemoteTarget() {
t.Fatal("hasSelectedRemoteTarget() = true, want false before selecting a saved remote connection")
}
u.applyRecentRemoteRecord(recentRemoteRecord{
- BaseURL: "https://dav.crew.example.invalid",
- Path: "vaults/bellagio.kdbx",
- Username: "dannyocean",
- Password: "topsecret",
+ BaseURL: "https://dav.crew.example.invalid",
+ Path: "vaults/bellagio.kdbx",
})
if !u.hasSelectedRemoteTarget() {
@@ -4459,6 +5526,1157 @@ func TestApplyingRecentRemoteRecordMarksSelectedRemoteConnection(t *testing.T) {
}
}
+func TestUIAvailableRemoteProfilesUsesVaultProfiles(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ RemoteProfiles: []vault.RemoteProfile{
+ {
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ },
+ {
+ ID: "archive-webdav",
+ Name: "Archive Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/archive.kdbx",
+ },
+ },
+ })
+
+ got := u.availableRemoteProfiles()
+ if len(got) != 2 {
+ t.Fatalf("len(availableRemoteProfiles()) = %d, want 2", len(got))
+ }
+ if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" {
+ t.Fatalf("availableRemoteProfiles() = %#v, want profiles sorted by name/id", got)
+ }
+}
+
+func TestUIAvailableRemoteCredentialEntriesUsesVaultEntries(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", 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"}},
+ },
+ })
+
+ got := u.availableRemoteCredentialEntries()
+ if len(got) != 2 {
+ t.Fatalf("len(availableRemoteCredentialEntries()) = %d, want 2", len(got))
+ }
+ if got[0].ID != "cred-1" || got[1].ID != "cred-2" {
+ t.Fatalf("availableRemoteCredentialEntries() = %#v, want entries sorted by title", got)
+ }
+}
+
+func TestUIAvailableRemoteProfilesReturnsEmptyWhenLocked(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", summarySession{locked: true})
+ if got := u.availableRemoteProfiles(); len(got) != 0 {
+ t.Fatalf("availableRemoteProfiles() = %#v, want empty when locked", got)
+ }
+}
+
+func TestUISelectVaultRemoteProfileUpdatesSelectionAndTargetFields(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+
+ u.selectVaultRemoteProfile("family-webdav")
+
+ if got := u.selectedVaultRemoteProfileID; got != "family-webdav" {
+ t.Fatalf("selectedVaultRemoteProfileID = %q, want family-webdav", got)
+ }
+ if got := u.remoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("remoteBaseURL = %q, want resolved profile base URL", got)
+ }
+ if got := u.remotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("remotePath = %q, want resolved profile path", got)
+ }
+}
+
+func TestUISelectVaultRemoteCredentialEntryUpdatesSelection(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ })
+
+ u.selectVaultRemoteCredentialEntry("remote-creds-1")
+
+ if got := u.selectedVaultRemoteCredentialEntryID; got != "remote-creds-1" {
+ t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want remote-creds-1", got)
+ }
+}
+
+func TestUIShouldShowSavedRemoteBindingSelectorsWhenMultipleChoices(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{
+ {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
+ {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}},
+ },
+ RemoteProfiles: []vault.RemoteProfile{
+ {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"},
+ {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"},
+ },
+ })
+
+ if !u.shouldShowSavedRemoteBindingSelectors() {
+ t.Fatal("shouldShowSavedRemoteBindingSelectors() = false, want true with multiple profiles and credentials")
+ }
+}
+
+func TestUIShouldHideSavedRemoteBindingSelectorsForSingleChoice(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+
+ if u.shouldShowSavedRemoteBindingSelectors() {
+ t.Fatal("shouldShowSavedRemoteBindingSelectors() = true, want false with a single saved binding choice")
+ }
+}
+
+func TestUISavedRemoteBindingSummaryUsesImplicitSingleChoice(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+
+ profileLabel, credentialLabel, syncLabel, ok := u.savedRemoteBindingSummary()
+ if !ok {
+ t.Fatal("savedRemoteBindingSummary() ok = false, want true")
+ }
+ if profileLabel != "Family Vault" {
+ t.Fatalf("profileLabel = %q, want Family Vault", profileLabel)
+ }
+ if credentialLabel != "Bellagio WebDAV Sign-In · linuscaldwell" {
+ t.Fatalf("credentialLabel = %q, want Bellagio WebDAV Sign-In · linuscaldwell", credentialLabel)
+ }
+ if syncLabel != "Sync manually when you choose Use Remote Sync." {
+ t.Fatalf("syncLabel = %q, want manual sync summary", syncLabel)
+ }
+}
+
+func TestUISavedRemoteBindingSummaryMentionsAutomaticSyncMode(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.selectedVaultRemoteSyncMode = appstate.SyncModeAutomaticOnOpenSave
+
+ _, _, syncLabel, ok := u.savedRemoteBindingSummary()
+ if !ok {
+ t.Fatal("savedRemoteBindingSummary() ok = false, want true")
+ }
+ if syncLabel != "Syncs automatically on open and save." {
+ t.Fatalf("syncLabel = %q, want automatic sync summary", syncLabel)
+ }
+}
+
+func TestUISavedRemoteBindingHeadingUsesSyncLanguageForSingleChoice(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+
+ if got := u.savedRemoteBindingHeading(); got != "Use this vault's saved remote sync target" {
+ t.Fatalf("savedRemoteBindingHeading() = %q, want sync-target guidance", got)
+ }
+}
+
+func TestUIOpenSelectedVaultRemoteButtonLabelUsesSyncLanguageForSingleChoice(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+
+ if got := u.openSelectedVaultRemoteButtonLabel(); got != "Use Remote Sync" {
+ t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Use Remote Sync", got)
+ }
+}
+
+func TestUIOpenSelectedVaultRemoteButtonLabelUsesSavedRemoteLanguageForMultipleChoices(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{
+ {ID: "remote-creds-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
+ {ID: "remote-creds-2", Title: "Bravo Sign-In", Username: "frankcatton", Path: []string{"Crew", "Internet"}},
+ },
+ RemoteProfiles: []vault.RemoteProfile{
+ {ID: "profile-1", Name: "Bellagio Vault", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav1.example.invalid", Path: "files/bellagio.kdbx"},
+ {ID: "profile-2", Name: "Vault Console", Backend: vault.RemoteBackendWebDAV, BaseURL: "https://dav2.example.invalid", Path: "files/console.kdbx"},
+ },
+ })
+
+ if got := u.openSelectedVaultRemoteButtonLabel(); got != "Open Saved Remote" {
+ t.Fatalf("openSelectedVaultRemoteButtonLabel() = %q, want Open Saved Remote", got)
+ }
+}
+
+func TestUIShouldShowDirectRemoteSyncShortcutForSavedBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true for an opened vault with a saved remote binding")
+ }
+}
+
+func TestUIRemoteSyncShortcutsHaveParityAcrossModes(t *testing.T) {
+ t.Parallel()
+
+ for _, mode := range []string{"desktop", "phone"} {
+ mode := mode
+ t.Run(mode, func(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel(mode, vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = false, want true")
+ }
+ if !u.shouldShowRemoteSyncSettingsShortcut() {
+ t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true")
+ }
+ if !u.shouldShowRemoveRemoteSyncShortcut() {
+ t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true")
+ }
+ if u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when a binding exists")
+ }
+
+ if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" {
+ t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got)
+ }
+ if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" {
+ t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got)
+ }
+ if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" {
+ t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got)
+ }
+ })
+ }
+}
+
+func TestUIShouldHideDirectRemoteSyncShortcutWithoutSavedBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{})
+ u.state.Section = appstate.SectionEntries
+
+ if u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a saved remote binding")
+ }
+}
+
+func TestUIDirectRemoteSyncShortcutLabelUsesSyncLanguage(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.directRemoteSyncShortcutLabel(); got != "Use Remote Sync" {
+ t.Fatalf("directRemoteSyncShortcutLabel() = %q, want Use Remote Sync", got)
+ }
+}
+
+func TestUIShouldShowRemoteSyncSetupShortcutForOpenedLocalVaultWithoutSavedBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "entry-1",
+ Title: "Vault Console",
+ Path: []string{"Crew", "Internet"},
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true for opened local vault without saved binding")
+ }
+}
+
+func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) {
+ t.Parallel()
+
+ for _, mode := range []string{"desktop", "phone"} {
+ mode := mode
+ t.Run(mode, func(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel(mode, vault.Model{
+ Entries: []vault.Entry{{
+ ID: "entry-1",
+ Title: "Vault Console",
+ Path: []string{"Crew", "Internet"},
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true")
+ }
+ if u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false without a binding")
+ }
+ if u.shouldShowRemoteSyncSettingsShortcut() {
+ t.Fatal("shouldShowRemoteSyncSettingsShortcut() = true, want false without a binding")
+ }
+ if u.shouldShowRemoveRemoteSyncShortcut() {
+ t.Fatal("shouldShowRemoveRemoteSyncShortcut() = true, want false without a binding")
+ }
+ if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" {
+ t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got)
+ }
+ })
+ }
+}
+
+func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = true, want false when saved binding already exists")
+ }
+}
+
+func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.remoteSyncSetupShortcutLabel(); got != "Set Up Remote Sync" {
+ t.Fatalf("remoteSyncSetupShortcutLabel() = %q, want Set Up Remote Sync", got)
+ }
+}
+
+func TestUIShouldShowRemoteSyncSettingsShortcutForSavedBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowRemoteSyncSettingsShortcut() {
+ t.Fatal("shouldShowRemoteSyncSettingsShortcut() = false, want true when a saved binding exists")
+ }
+}
+
+func TestUIRemoteSyncSettingsShortcutLabelUsesClearLanguage(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.remoteSyncSettingsShortcutLabel(); got != "Remote Sync Settings" {
+ t.Fatalf("remoteSyncSettingsShortcutLabel() = %q, want Remote Sync Settings", got)
+ }
+}
+
+func TestUIShouldShowRemoveRemoteSyncShortcutForSavedBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Path: []string{"Crew", "Internet"},
+ }},
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+
+ if !u.shouldShowRemoveRemoteSyncShortcut() {
+ t.Fatal("shouldShowRemoveRemoteSyncShortcut() = false, want true when a saved binding exists")
+ }
+}
+
+func TestUIRemoveRemoteSyncShortcutLabelUsesClearLanguage(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.removeRemoteSyncShortcutLabel(); got != "Stop Using Remote Sync" {
+ t.Fatalf("removeRemoteSyncShortcutLabel() = %q, want Stop Using Remote Sync", got)
+ }
+}
+
+func TestUIOpenRemoteSyncSetupDialogPrefillsCurrentVaultSetupFlow(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.vaultPath.SetText("/vaults/family.kdbx")
+
+ u.openRemoteSyncSetupDialog()
+
+ if !u.syncDialogOpen {
+ t.Fatal("syncDialogOpen = false, want true")
+ }
+ if got := u.syncDialogPurpose; got != syncDialogPurposeRemoteSetup {
+ t.Fatalf("syncDialogPurpose = %q, want remote setup", got)
+ }
+ if got := u.syncSourceMode; got != syncSourceRemote {
+ t.Fatalf("syncSourceMode = %q, want remote", got)
+ }
+ if got := u.syncDirection; got != syncDirectionPush {
+ t.Fatalf("syncDirection = %q, want push", got)
+ }
+ if got := u.syncLocalPath.Text(); got != "/vaults/family.kdbx" {
+ t.Fatalf("syncLocalPath = %q, want current vault path", got)
+ }
+ if got := u.syncRemoteBaseURL.Text(); got != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("syncRemoteBaseURL = %q, want saved remote base URL", got)
+ }
+ if got := u.syncRemotePath.Text(); got != "files/family/keepass.kdbx" {
+ t.Fatalf("syncRemotePath = %q, want saved remote path", got)
+ }
+ if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" {
+ t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got)
+ }
+ if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" {
+ t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got)
+ }
+}
+
+func TestUISelectedLocalVaultRemoteSyncSummaryMentionsSetup(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+
+ if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Open this vault to set up a WebDAV sync target for it." {
+ t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want setup guidance", got)
+ }
+}
+
+func TestUISelectedLocalVaultRemoteSyncSummaryMentionsAutomaticSync(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: "/vaults/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ }}
+
+ if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Syncs automatically on open and save." {
+ t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want automatic sync guidance", got)
+ }
+}
+
+func TestUISelectedLocalVaultRemoteSyncSummaryMentionsManualSync(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: "/vaults/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeManual),
+ }}
+
+ if got := u.selectedLocalVaultRemoteSyncSummary("/vaults/family.kdbx"); got != "Saved remote sync target: keepass.kdbx · dav.example.invalid · Sync manually when you choose Use Remote Sync." {
+ t.Fatalf("selectedLocalVaultRemoteSyncSummary() = %q, want manual sync guidance", got)
+ }
+}
+
+func TestUISyncDialogUsesRemoteSetupCopy(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.syncDialogPurpose = syncDialogPurposeRemoteSetup
+ u.syncSetupAutomatic.Value = true
+
+ if got := u.syncDialogTitle(); got != "Set Up Remote Sync" {
+ t.Fatalf("syncDialogTitle() = %q, want Set Up Remote Sync", got)
+ }
+ if got := u.syncDialogDescription(); got != "Send this local vault to a WebDAV target, then use that target for future sync." {
+ t.Fatalf("syncDialogDescription() = %q, want remote setup guidance", got)
+ }
+ if got := u.syncDialogConfirmButtonLabel(); got != "Set Up Remote Sync" {
+ t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Set Up Remote Sync", got)
+ }
+ if u.shouldShowSyncDirectionChoices() {
+ t.Fatal("shouldShowSyncDirectionChoices() = true, want false for remote setup")
+ }
+ if u.shouldShowSyncSourceChoices() {
+ t.Fatal("shouldShowSyncSourceChoices() = true, want false for remote setup")
+ }
+ if got := syncDialogSummaryText(syncDialogPurposeRemoteSetup, syncSourceRemote, syncDirectionPush); got != "Push this local vault to a WebDAV target and save that target for future sync." {
+ t.Fatalf("syncDialogSummaryText(remote setup) = %q, want setup-specific summary", got)
+ }
+ if got := u.syncSetupMode(); got != appstate.SyncModeAutomaticOnOpenSave {
+ t.Fatalf("syncSetupMode() = %q, want automatic_on_open_save", got)
+ }
+}
+
+func TestUISyncDialogUsesRemoteSettingsCopyForExistingBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ })
+ u.state.Section = appstate.SectionEntries
+ u.vaultPath.SetText("/vaults/family.kdbx")
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: "/vaults/family.kdbx",
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeManual),
+ }}
+
+ u.openRemoteSyncSetupDialog()
+
+ if got := u.syncDialogTitle(); got != "Remote Sync Settings" {
+ t.Fatalf("syncDialogTitle() = %q, want Remote Sync Settings", got)
+ }
+ if got := u.syncDialogDescription(); got != "Review or change this vault's saved WebDAV target, credentials, and sync mode." {
+ t.Fatalf("syncDialogDescription() = %q, want settings guidance", got)
+ }
+ if got := u.syncDialogConfirmButtonLabel(); got != "Save Remote Sync Settings" {
+ t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Save Remote Sync Settings", got)
+ }
+ if u.syncSetupAutomatic.Value {
+ t.Fatal("syncSetupAutomatic.Value = true, want false for an existing manual binding")
+ }
+}
+
+func TestUISyncDialogUsesAdvancedCopy(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.syncDialogPurpose = syncDialogPurposeAdvanced
+
+ if got := u.syncDialogTitle(); got != "Advanced Sync" {
+ t.Fatalf("syncDialogTitle() = %q, want Advanced Sync", got)
+ }
+ if got := u.syncDialogConfirmButtonLabel(); got != "Synchronize" {
+ t.Fatalf("syncDialogConfirmButtonLabel() = %q, want Synchronize", got)
+ }
+ if !u.shouldShowSyncDirectionChoices() {
+ t.Fatal("shouldShowSyncDirectionChoices() = false, want true for advanced sync")
+ }
+ if !u.shouldShowSyncSourceChoices() {
+ t.Fatal("shouldShowSyncSourceChoices() = false, want true for advanced sync")
+ }
+ if got := syncDialogSummaryText(syncDialogPurposeAdvanced, syncSourceRemote, syncDirectionPush); got != "Push the current vault into another WebDAV-backed vault." {
+ t.Fatalf("syncDialogSummaryText(advanced) = %q, want advanced summary", got)
+ }
+}
+
+func TestUIRemoteSyncSetupPersistsBindingAfterSuccessfulPush(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ currentPath := filepath.Join(t.TempDir(), "current.kdbx")
+ writeKDBXMainTestFile(t, currentPath, vault.Model{
+ Entries: []vault.Entry{{
+ ID: "entry-current",
+ Title: "Vault Console",
+ Username: "dannyocean",
+ Password: "token-current",
+ URL: "https://vault.crew.example.invalid",
+ Path: []string{"Root", "Internet"},
+ }},
+ }, key)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ w.WriteHeader(http.StatusNotFound)
+ case http.MethodPut:
+ w.Header().Set("ETag", "\"v1\"")
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ }))
+ defer server.Close()
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+ u.masterPassword.SetText(key.Password)
+ u.vaultPath.SetText(currentPath)
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+
+ u.openRemoteSyncSetupDialog()
+ u.syncRemoteBaseURL.SetText(server.URL)
+ u.syncRemotePath.SetText("vaults/other.kdbx")
+ u.syncRemoteUsername.SetText("linuscaldwell")
+ u.syncRemotePassword.SetText("bellagio-pass-1")
+
+ if err := u.advancedSyncAction(); err != nil {
+ t.Fatalf("advancedSyncAction() error = %v", err)
+ }
+
+ if got := u.selectedVaultRemoteProfileID; got == "" {
+ t.Fatal("selectedVaultRemoteProfileID = empty, want saved binding")
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got == "" {
+ t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want saved credential binding")
+ }
+ if got := u.remoteBaseURL.Text(); got != server.URL {
+ t.Fatalf("remoteBaseURL = %q, want %q", got, server.URL)
+ }
+ if got := u.remotePath.Text(); got != "vaults/other.kdbx" {
+ t.Fatalf("remotePath = %q, want vaults/other.kdbx", got)
+ }
+ if got := len(u.recentRemotes); got != 1 {
+ t.Fatalf("len(recentRemotes) = %d, want 1", got)
+ }
+ if got := u.recentRemotes[0].LocalVaultPath; got != currentPath {
+ t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", got, currentPath)
+ }
+ if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeAutomaticOnOpenSave {
+ t.Fatalf("selectedVaultRemoteSyncMode = %q, want automatic_on_open_save", got)
+ }
+ if got := u.state.StatusMessage; got != "Remote sync is set up for this vault." {
+ t.Fatalf("StatusMessage = %q, want setup success message", got)
+ }
+ if u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = true after setup, want false")
+ }
+ if !u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = false after setup, want true")
+ }
+
+ var reopened session.Manager
+ if err := reopened.Open(currentPath, key); err != nil {
+ t.Fatalf("reopened.Open(currentPath) error = %v", err)
+ }
+ reopenedModel, err := reopened.Current()
+ if err != nil {
+ t.Fatalf("reopened.Current() error = %v", err)
+ }
+ profiles := reopenedModel.RemoteProfiles
+ if len(profiles) != 1 {
+ t.Fatalf("len(reopened.RemoteProfiles) = %d, want 1 persisted profile", len(profiles))
+ }
+ if profiles[0].BaseURL != server.URL || profiles[0].Path != "vaults/other.kdbx" {
+ t.Fatalf("reopened.RemoteProfiles[0] = %#v, want persisted setup target", profiles[0])
+ }
+ cred, err := reopenedModel.EntryByID(u.selectedVaultRemoteCredentialEntryID)
+ if err != nil {
+ t.Fatalf("reopened.EntryByID(saved credential) error = %v", err)
+ }
+ if cred.Username != "linuscaldwell" || cred.Password != "bellagio-pass-1" {
+ t.Fatalf("reopened saved credential = %#v, want linuscaldwell/bellagio-pass-1", cred)
+ }
+}
+
+func TestUIRemoteSyncSetupCanPersistManualSyncMode(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ currentPath := filepath.Join(t.TempDir(), "current.kdbx")
+ writeKDBXMainTestFile(t, currentPath, vault.Model{
+ Entries: []vault.Entry{{ID: "entry-current", Title: "Vault Console", Path: []string{"Root", "Internet"}}},
+ }, key)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ w.WriteHeader(http.StatusNotFound)
+ case http.MethodPut:
+ w.Header().Set("ETag", "\"v1\"")
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Fatalf("unexpected method %s", r.Method)
+ }
+ }))
+ defer server.Close()
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+ u.masterPassword.SetText(key.Password)
+ u.vaultPath.SetText(currentPath)
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+
+ u.openRemoteSyncSetupDialog()
+ u.syncSetupAutomatic.Value = false
+ u.syncRemoteBaseURL.SetText(server.URL)
+ u.syncRemotePath.SetText("vaults/manual.kdbx")
+ u.syncRemoteUsername.SetText("linuscaldwell")
+ u.syncRemotePassword.SetText("bellagio-pass-1")
+
+ if err := u.advancedSyncAction(); err != nil {
+ t.Fatalf("advancedSyncAction() error = %v", err)
+ }
+
+ if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual {
+ t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got)
+ }
+ if got := u.recentRemotes[0].SyncMode; got != string(appstate.SyncModeManual) {
+ t.Fatalf("recentRemotes[0].SyncMode = %q, want manual", got)
+ }
+}
+
+func TestUIRemoveSelectedRemoteBindingActionClearsVaultBindingAndRecentRefs(t *testing.T) {
+ t.Parallel()
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ currentPath := filepath.Join(t.TempDir(), "current.kdbx")
+ writeKDBXMainTestFile(t, currentPath, 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: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }, key)
+
+ dir := t.TempDir()
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ })
+ u.masterPassword.SetText(key.Password)
+ u.vaultPath.SetText(currentPath)
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: currentPath,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ SyncMode: string(appstate.SyncModeAutomaticOnOpenSave),
+ }}
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() error = %v", err)
+ }
+
+ if err := u.removeSelectedRemoteBindingAction(); err != nil {
+ t.Fatalf("removeSelectedRemoteBindingAction() error = %v", err)
+ }
+
+ if got := u.selectedVaultRemoteProfileID; got != "" {
+ t.Fatalf("selectedVaultRemoteProfileID = %q, want empty", got)
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got != "" {
+ t.Fatalf("selectedVaultRemoteCredentialEntryID = %q, want empty", got)
+ }
+ if got := u.selectedVaultRemoteSyncMode; got != appstate.SyncModeManual {
+ t.Fatalf("selectedVaultRemoteSyncMode = %q, want manual", got)
+ }
+ if got := u.recentRemotes[0].RemoteProfileID; got != "" {
+ t.Fatalf("recentRemotes[0].RemoteProfileID = %q, want empty", got)
+ }
+ if got := u.recentRemotes[0].CredentialEntryID; got != "" {
+ t.Fatalf("recentRemotes[0].CredentialEntryID = %q, want empty", got)
+ }
+ if got := u.recentRemotes[0].SyncMode; got != "" {
+ t.Fatalf("recentRemotes[0].SyncMode = %q, want empty", got)
+ }
+ if got := u.state.StatusMessage; got != "Remote sync is no longer set up for this vault." {
+ t.Fatalf("StatusMessage = %q, want removal status", got)
+ }
+ if u.shouldShowDirectRemoteSyncShortcut() {
+ t.Fatal("shouldShowDirectRemoteSyncShortcut() = true, want false after removing binding")
+ }
+ if !u.shouldShowRemoteSyncSetupShortcut() {
+ t.Fatal("shouldShowRemoteSyncSetupShortcut() = false, want true after removing binding")
+ }
+
+ reopened := newUIWithSession("desktop", &session.Manager{})
+ reopened.masterPassword.SetText(key.Password)
+ reopened.vaultPath.SetText(currentPath)
+ if err := reopened.openVaultAction(); err != nil {
+ t.Fatalf("reopened.openVaultAction() error = %v", err)
+ }
+ if got := len(reopened.availableRemoteProfiles()); got != 0 {
+ t.Fatalf("len(reopened.availableRemoteProfiles()) = %d, want 0", got)
+ }
+ if got := len(reopened.availableRemoteCredentialEntries()); got != 0 {
+ t.Fatalf("len(reopened.availableRemoteCredentialEntries()) = %d, want 0", got)
+ }
+}
+
+func TestUISaveCurrentRemoteBindingActionPersistsBindingIntoVault(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{})
+ u.currentPath = []string{"Crew", "Internet"}
+ u.vaultPath.SetText("/tmp/family.kdbx")
+ u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
+ u.remotePath.SetText("files/family/keepass.kdbx")
+ u.remoteUsername.SetText("linuscaldwell")
+ u.remotePassword.SetText("bellagio-pass-1")
+
+ if err := u.saveCurrentRemoteBindingAction(); err != nil {
+ t.Fatalf("saveCurrentRemoteBindingAction() error = %v", err)
+ }
+
+ profiles := u.availableRemoteProfiles()
+ if len(profiles) != 1 {
+ t.Fatalf("len(availableRemoteProfiles()) = %d, want 1", len(profiles))
+ }
+ if profiles[0].BaseURL != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("saved profile = %#v, want persisted base URL", profiles[0])
+ }
+
+ entries := u.availableRemoteCredentialEntries()
+ var found bool
+ for _, entry := range entries {
+ if entry.Username == "linuscaldwell" && entry.Password == "bellagio-pass-1" {
+ found = true
+ if !slices.Equal(entry.Path, []string{"Crew", "Internet"}) {
+ t.Fatalf("credential path = %v, want [Crew Internet]", entry.Path)
+ }
+ if entry.URL != "https://dav.example.invalid/remote.php/dav" {
+ t.Fatalf("credential URL = %q, want remote.php/dav URL", entry.URL)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("availableRemoteCredentialEntries() = %#v, want persisted linuscaldwell/bellagio-pass-1 entry", entries)
+ }
+
+ if got := u.selectedVaultRemoteProfileID; got == "" {
+ t.Fatal("selectedVaultRemoteProfileID = empty, want selected saved profile")
+ }
+ if got := u.selectedVaultRemoteCredentialEntryID; got == "" {
+ t.Fatal("selectedVaultRemoteCredentialEntryID = empty, want selected saved credential entry")
+ }
+ if !u.state.Dirty {
+ t.Fatal("state.Dirty = false, want true after saving binding into vault")
+ }
+}
+
+func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Bellagio", Username: "rustyryan", URL: "https://dav.example.invalid/remote.php/dav/", Path: []string{"Crew", "Internet"}},
+ {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
+ {ID: "entry-3", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Internet"}},
+ },
+ })
+ u.syncSourceMode = syncSourceRemote
+ u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
+
+ got := u.matchingAdvancedSyncRemoteCredentialEntries()
+ if len(got) != 2 {
+ t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got))
+ }
+ if got[0].ID != "entry-1" || got[1].ID != "entry-3" {
+ t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want Bellagio and Bellagio WebDAV Sign-In matches", got)
+ }
+}
+
+func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) {
+ t.Parallel()
+
+ localVaultPath := filepath.Join(t.TempDir(), "family.kdbx")
+ u := newUIWithState("desktop", &uiSession{model: vault.Model{
+ Entries: []vault.Entry{
+ {ID: "remote-creds-1", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", Path: []string{"Crew", "Internet"}},
+ {ID: "entry-2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
+ },
+ RemoteProfiles: []vault.RemoteProfile{{
+ ID: "family-webdav",
+ Name: "Family Vault",
+ Backend: vault.RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ }},
+ }}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "default.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.vaultPath.SetText(localVaultPath)
+ u.syncSourceMode = syncSourceRemote
+ u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
+ u.syncRemotePath.SetText("files/family/keepass.kdbx")
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/keepass.kdbx",
+ LocalVaultPath: localVaultPath,
+ RemoteProfileID: "family-webdav",
+ CredentialEntryID: "remote-creds-1",
+ }}
+
+ got := u.matchingAdvancedSyncRemoteCredentialEntries()
+ if len(got) != 1 {
+ t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 1 from saved binding", len(got))
+ }
+ if got[0].ID != "remote-creds-1" {
+ t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() = %#v, want remote-creds-1 from saved binding", got)
+ }
+}
+
+func TestUIApplyAdvancedSyncRemoteCredentialEntryFillsCredentials(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{})
+ entry := vault.Entry{
+ ID: "remote-creds-1",
+ Title: "Bellagio WebDAV Sign-In",
+ Username: "linuscaldwell",
+ Password: "bellagio-pass-1",
+ URL: "https://dav.example.invalid/remote.php/dav",
+ }
+
+ u.applyAdvancedSyncRemoteCredentialEntry(entry)
+
+ if got := u.syncRemoteUsername.Text(); got != "linuscaldwell" {
+ t.Fatalf("syncRemoteUsername = %q, want linuscaldwell", got)
+ }
+ if got := u.syncRemotePassword.Text(); got != "bellagio-pass-1" {
+ t.Fatalf("syncRemotePassword = %q, want bellagio-pass-1", got)
+ }
+ if got := u.selectedSyncRemoteCredentialEntryID; got != "remote-creds-1" {
+ t.Fatalf("selectedSyncRemoteCredentialEntryID = %q, want remote-creds-1", got)
+ }
+}
+
+func TestUISaveCurrentRemoteBindingActionRequiresCompleteRemoteSignIn(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithModel("desktop", vault.Model{})
+ u.vaultPath.SetText("/tmp/family.kdbx")
+ u.remoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
+ u.remotePath.SetText("files/family/keepass.kdbx")
+
+ if err := u.saveCurrentRemoteBindingAction(); err == nil {
+ t.Fatal("saveCurrentRemoteBindingAction() error = nil, want validation error")
+ }
+}
+
func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) {
t.Parallel()
@@ -4469,7 +6687,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) {
u.remotePath.SetText("vaults/remote.kdbx")
u.remoteUsername.SetText("dannyocean")
u.remotePassword.SetText("topsecret")
- u.rememberRemoteAuth.Value = true
u.masterPassword.SetText("correct horse battery staple")
u.keyFilePath.SetText("/vaults/keyfile.keyx")
u.search.SetText("crew")
@@ -4501,9 +6718,6 @@ func TestSwitchToLifecycleSelectionResetsLockedLocalSession(t *testing.T) {
if got := u.remotePassword.Text(); got != "" {
t.Fatalf("remotePassword = %q, want empty", got)
}
- if u.rememberRemoteAuth.Value {
- t.Fatal("rememberRemoteAuth = true, want false")
- }
if got := u.masterPassword.Text(); got != "" {
t.Fatalf("masterPassword = %q, want empty", got)
}
@@ -4537,7 +6751,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) {
u.remotePath.SetText("vaults/remote.kdbx")
u.remoteUsername.SetText("rustyryan")
u.remotePassword.SetText("topsecret")
- u.rememberRemoteAuth.Value = true
u.switchToLifecycleSelection("remote")
@@ -4562,9 +6775,6 @@ func TestSwitchToLifecycleSelectionResetsLockedRemoteSession(t *testing.T) {
if got := u.remotePassword.Text(); got != "" {
t.Fatalf("remotePassword = %q, want empty", got)
}
- if u.rememberRemoteAuth.Value {
- t.Fatal("rememberRemoteAuth = true, want false")
- }
}
func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) {
@@ -4644,90 +6854,43 @@ func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) {
}
}
-func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- record recentRemoteRecord
- want string
- }{
- {
- name: "location_only",
- record: recentRemoteRecord{},
- want: "location only",
- },
- {
- name: "username_only",
- record: recentRemoteRecord{Username: "alice"},
- want: "saved username",
- },
- {
- name: "password_only",
- record: recentRemoteRecord{Password: "token-1"},
- want: "saved password",
- },
- {
- name: "full_sign_in",
- record: recentRemoteRecord{Username: "alice", Password: "token-1"},
- want: "saved username and password",
- },
- }
-
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want {
- t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want)
- }
- })
- }
-}
-
-func TestUIRemotePreferencesCurrentSummaryExplainsWhatWillBeRemembered(t *testing.T) {
+func TestUIRemotePreferencesCurrentSummaryExplainsVaultBackedCredentialFlow(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.remoteBaseURL.SetText("https://dav.example.com")
u.remotePath.SetText("vaults/home.kdbx")
- if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO will remember only the WebDAV location for this connection." {
- t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only guidance", got)
+ if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO remembers this connection's location only. Remote credentials belong in the vault, not device state." {
+ t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only vault guidance", got)
}
- u.rememberRemoteAuth.Value = true
- if got := u.remotePreferencesCurrentSummary(); got != "Current choice: sign-in retention is enabled, but no username or password is entered yet." {
- t.Fatalf("remotePreferencesCurrentSummary() = %q, want empty-sign-in guidance", got)
- }
-
- u.remoteUsername.SetText("alice")
- if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will save the entered sign-in for this connection on this device." {
- t.Fatalf("remotePreferencesCurrentSummary() = %q, want pending-save guidance", got)
- }
-
- u.recentRemotes = []recentRemoteRecord{{
- BaseURL: "https://dav.example.com",
- Path: "vaults/home.kdbx",
- Username: "alice",
- Password: "secret-1",
- }}
- if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will update the saved sign-in for this connection on this device." {
- t.Fatalf("remotePreferencesCurrentSummary() = %q, want saved-sign-in guidance", got)
+ u.remoteUsername.SetText("debbieocean")
+ if got := u.remotePreferencesCurrentSummary(); got != "Current choice: the entered WebDAV sign-in is used for this open. To persist it, store it in the vault and bind this vault to the remote profile." {
+ t.Fatalf("remotePreferencesCurrentSummary() = %q, want vault-storage guidance", got)
}
}
-func TestUIRemotePreferencesHelpExplainsSavedFieldsAndRetention(t *testing.T) {
+func TestUIRemotePreferencesHelpExplainsLocationOnlyRetention(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
- if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." {
+ if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections stores only the WebDAV base URL, remote path, and the last group you opened for that connection." {
t.Fatalf("remotePreferencesAlwaysSavedSummary() = %q, want saved-fields guidance", got)
}
- if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." {
- t.Fatalf("remotePreferencesRetentionSummary() = %q, want retention guidance", got)
+ if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Store remote credentials in the vault if this connection should persist across devices or reinstalls." {
+ t.Fatalf("remotePreferencesRetentionSummary() = %q, want vault retention guidance", got)
+ }
+}
+
+func TestUIRemotePreferencesPersistenceSummaryExplainsVaultBindingFlow(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.remotePreferencesPersistenceSummary(); got != "After a successful remote open, KeePassGO can keep a local cache vault and store the shared remote target plus this user's credential entry in the vault itself." {
+ t.Fatalf("remotePreferencesPersistenceSummary() = %q, want local-first vault-binding guidance", got)
}
}
@@ -4760,13 +6923,171 @@ func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) {
u := newUIWithSession("desktop", &session.Manager{})
u.lifecycleMode = "remote"
- if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" {
- t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault")
+ if got := u.remoteOpenButtonLabel(); got != "Create Local Cache" {
+ t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Create Local Cache")
}
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
- if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" {
- t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault")
+ if got := u.remoteOpenButtonLabel(); got != "Retry Local Cache Setup" {
+ t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Local Cache Setup")
+ }
+
+ u.loadingMessage = "Opening..."
+ u.state.ErrorMessage = ""
+ if got := u.remoteOpenButtonLabel(); got != "Creating Local Cache..." {
+ t.Fatalf("remoteOpenButtonLabel() while busy = %q, want %q", got, "Creating Local Cache...")
+ }
+}
+
+func TestUIRemoteLifecycleMessageUsesLocalCacheLanguageForBoundRemote(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ LocalVaultPath: "/vaults/cache/home.kdbx",
+ RemoteProfileID: "remote-profile-1",
+ CredentialEntryID: "remote-creds-1",
+ })
+
+ if got := u.remoteLifecycleMessage(); got != "Open the local cache for this remote vault, then unlock and sync it with the vault-stored remote settings." {
+ t.Fatalf("remoteLifecycleMessage() = %q, want local-cache guidance", got)
+ }
+}
+
+func TestUIRemoteLifecycleMessageUsesLocalFirstSetupLanguageForFirstRemoteOpen(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+
+ if got := u.remoteLifecycleMessage(); got != "Open a remote vault to create this device's local cache. After the first open, save the remote in the vault to reuse remote sync directly." {
+ t.Fatalf("remoteLifecycleMessage() = %q, want local-first remote setup guidance", got)
+ }
+}
+
+func TestUIRemoteLifecycleSetupSummaryExplainsCacheAndBindingFlow(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.remoteLifecycleSetupSummary(); got != "The first remote open creates a local KDBX cache on this device. Save the remote in the vault afterward to turn that cache into a reusable sync target." {
+ t.Fatalf("remoteLifecycleSetupSummary() = %q, want local-cache bootstrap guidance", got)
+ }
+}
+
+func TestUISaveCurrentRemoteBindingHeadingExplainsVaultBinding(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.saveCurrentRemoteBindingHeading(); got != "Bind this local vault to the current remote target" {
+ t.Fatalf("saveCurrentRemoteBindingHeading() = %q, want vault binding guidance", got)
+ }
+}
+
+func TestUISaveCurrentRemoteBindingButtonLabelUsesSyncLanguage(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+
+ if got := u.saveCurrentRemoteBindingButtonLabel(); got != "Save Remote In Vault" {
+ t.Fatalf("saveCurrentRemoteBindingButtonLabel() = %q, want sync-target language", got)
+ }
+}
+
+func TestUIRemoteOpenButtonLabelUsesLocalCacheLanguageForBoundRemote(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ LocalVaultPath: "/vaults/cache/home.kdbx",
+ RemoteProfileID: "remote-profile-1",
+ CredentialEntryID: "remote-creds-1",
+ })
+
+ if got := u.remoteOpenButtonLabel(); got != "Open Cached Vault" {
+ t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Cached Vault")
+ }
+
+ u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
+ if got := u.remoteOpenButtonLabel(); got != "Retry Cached Vault" {
+ t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Cached Vault")
+ }
+}
+
+func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ LocalVaultPath: "/vaults/cache/home.kdbx",
+ RemoteProfileID: "remote-profile-1",
+ CredentialEntryID: "remote-creds-1",
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ LocalVaultPath: "/vaults/cache/home.kdbx",
+ RemoteProfileID: "remote-profile-1",
+ CredentialEntryID: "remote-creds-1",
+ LastGroup: []string{"Root", "Internet"},
+ }}
+
+ if got := u.selectedRemoteCardHeading(); got != "CACHED VAULT" {
+ t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "CACHED VAULT")
+ }
+ if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx" {
+ t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx")
+ }
+ gotDetails := u.selectedRemoteCardDetailLines()
+ wantDetails := []string{
+ "/vaults/cache",
+ "Sync target: home.kdbx · dav.example.invalid",
+ "Last group: Root / Internet",
+ }
+ if !slices.Equal(gotDetails, wantDetails) {
+ t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
+ }
+}
+
+func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{})
+ u.lifecycleMode = "remote"
+ u.applyRecentRemoteRecord(recentRemoteRecord{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ })
+ u.recentRemotes = []recentRemoteRecord{{
+ BaseURL: "https://dav.example.invalid",
+ Path: "vaults/home.kdbx",
+ LastGroup: []string{"Root", "Internet"},
+ }}
+
+ if got := u.selectedRemoteCardHeading(); got != "SELECTED CONNECTION" {
+ t.Fatalf("selectedRemoteCardHeading() = %q, want %q", got, "SELECTED CONNECTION")
+ }
+ if got := u.selectedRemoteCardPrimaryText(); got != "home.kdbx · dav.example.invalid" {
+ t.Fatalf("selectedRemoteCardPrimaryText() = %q, want %q", got, "home.kdbx · dav.example.invalid")
+ }
+ gotDetails := u.selectedRemoteCardDetailLines()
+ wantDetails := []string{
+ "Path: vaults/home.kdbx",
+ "Server: https://dav.example.invalid",
+ "Last group: Root / Internet",
+ }
+ if !slices.Equal(gotDetails, wantDetails) {
+ t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
}
}
@@ -4796,9 +7117,14 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T)
}))
defer server.Close()
- first := newUIWithSession("desktop", &session.Manager{})
- first.recentRemotesPath = statePath
- first.recentRemotes = nil
+ paths := statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: statePath,
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ }
+
+ first := newUIWithState("desktop", &session.Manager{}, paths)
first.lifecycleMode = "remote"
first.masterPassword.SetText("correct horse battery staple")
first.remoteBaseURL.SetText(server.URL)
@@ -4811,10 +7137,7 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T)
first.syncedPath = []string{"Root", "Internet"}
first.noteCurrentRemotePath()
- reopened := newUIWithSession("desktop", &session.Manager{})
- reopened.recentRemotesPath = statePath
- reopened.recentRemotes = nil
- reopened.loadRecentRemotes()
+ reopened := newUIWithState("desktop", &session.Manager{}, paths)
reopened.lifecycleMode = "remote"
reopened.masterPassword.SetText("correct horse battery staple")
reopened.remoteBaseURL.SetText(server.URL)
@@ -4828,6 +7151,161 @@ func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T)
}
}
+func TestUIOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ cachePath := filepath.Join(dir, "remote-cache.kdbx")
+ paths := statePaths{
+ DefaultSaveAsPath: cachePath,
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ }
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
+ },
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ if r.Method != http.MethodGet {
+ t.Fatalf("method = %s, want GET", r.Method)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(encoded.Bytes())
+ }))
+ defer server.Close()
+
+ u := newUIWithState("desktop", &session.Manager{}, paths)
+ u.lifecycleMode = "remote"
+ u.masterPassword.SetText(key.Password)
+ u.remoteBaseURL.SetText(server.URL)
+ u.remotePath.SetText("vault.kdbx")
+ u.remoteUsername.SetText("linuscaldwell")
+ u.remotePassword.SetText("bellagio-pass-1")
+
+ if err := u.openRemoteAction(); err != nil {
+ t.Fatalf("openRemoteAction() error = %v", err)
+ }
+
+ if got := u.vaultPath.Text(); got != cachePath {
+ t.Fatalf("vaultPath = %q, want %q", got, cachePath)
+ }
+ if _, err := os.Stat(cachePath); err != nil {
+ t.Fatalf("Stat(cachePath) error = %v", err)
+ }
+ if got := len(u.recentRemotes); got != 1 {
+ t.Fatalf("len(recentRemotes) = %d, want 1", got)
+ }
+ record := u.recentRemotes[0]
+ if record.LocalVaultPath != cachePath {
+ t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath)
+ }
+ if record.RemoteProfileID == "" || record.CredentialEntryID == "" {
+ t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record)
+ }
+
+ var reopened session.Manager
+ if err := reopened.Open(cachePath, key); err != nil {
+ t.Fatalf("Open(cachePath) error = %v", err)
+ }
+ model, err := reopened.Current()
+ if err != nil {
+ t.Fatalf("Current() error = %v", err)
+ }
+ if got := len(model.RemoteProfiles); got != 1 {
+ t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
+ }
+ if got := model.RemoteProfiles[0].BaseURL; got != server.URL {
+ t.Fatalf("RemoteProfiles[0].BaseURL = %q, want %q", got, server.URL)
+ }
+ entry, err := model.EntryByID(record.CredentialEntryID)
+ if err != nil {
+ t.Fatalf("EntryByID(%q) error = %v", record.CredentialEntryID, err)
+ }
+ if entry.Username != "linuscaldwell" || entry.Password != "bellagio-pass-1" {
+ t.Fatalf("credential entry = %#v, want linuscaldwell/bellagio-pass-1", entry)
+ }
+}
+
+func TestUIStartOpenRemoteActionMaterializesLocalCacheAndBinding(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ cachePath := filepath.Join(dir, "remote-cache.kdbx")
+ paths := statePaths{
+ DefaultSaveAsPath: cachePath,
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ }
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
+ },
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != "linuscaldwell" || password != "bellagio-pass-1" {
+ t.Fatalf("BasicAuth() = (%q, %q, %t), want (linuscaldwell, bellagio-pass-1, true)", username, password, ok)
+ }
+ if r.Method != http.MethodGet {
+ t.Fatalf("method = %s, want GET", r.Method)
+ }
+ w.Header().Set("ETag", "\"v1\"")
+ _, _ = w.Write(encoded.Bytes())
+ }))
+ defer server.Close()
+
+ u := newUIWithState("desktop", &session.Manager{}, paths)
+ u.lifecycleMode = "remote"
+ u.masterPassword.SetText(key.Password)
+ u.remoteBaseURL.SetText(server.URL)
+ u.remotePath.SetText("vault.kdbx")
+ u.remoteUsername.SetText("linuscaldwell")
+ u.remotePassword.SetText("bellagio-pass-1")
+
+ u.startOpenRemoteAction()
+ result := waitForBackgroundResult(t, u)
+ u.applyBackgroundResult(result)
+
+ if got := u.state.ErrorMessage; got != "" {
+ t.Fatalf("ErrorMessage after apply = %q, want empty", got)
+ }
+ if got := u.vaultPath.Text(); got != cachePath {
+ t.Fatalf("vaultPath = %q, want %q", got, cachePath)
+ }
+ if _, err := os.Stat(cachePath); err != nil {
+ t.Fatalf("Stat(cachePath) error = %v", err)
+ }
+ if got := len(u.recentRemotes); got != 1 {
+ t.Fatalf("len(recentRemotes) = %d, want 1", got)
+ }
+ record := u.recentRemotes[0]
+ if record.LocalVaultPath != cachePath {
+ t.Fatalf("recentRemotes[0].LocalVaultPath = %q, want %q", record.LocalVaultPath, cachePath)
+ }
+ if record.RemoteProfileID == "" || record.CredentialEntryID == "" {
+ t.Fatalf("recentRemotes[0] = %#v, want binding ids populated", record)
+ }
+}
+
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
t.Parallel()
@@ -4852,6 +7330,191 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
}
+ if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") {
+ t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx"))
+ }
+ if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
+ t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
+ }
+}
+
+func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ })
+
+ got := u.importedVaultDestination("shared-home.kdbx")
+ want := filepath.Join(filepath.Dir(u.defaultSaveAsPath), "shared-home.kdbx")
+ if got != want {
+ t.Fatalf("importedVaultDestination() = %q, want %q", got, want)
+ }
+}
+
+func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ paths := statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ }
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
+ },
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+
+ u := newUIWithState("phone", &session.Manager{}, paths)
+ u.lifecycleMode = "remote"
+
+ if err := u.importSharedVaultBytesAction("shared-home.kdbx", encoded.Bytes()); err != nil {
+ t.Fatalf("importSharedVaultBytesAction() error = %v", err)
+ }
+
+ wantPath := filepath.Join(dir, "shared-home.kdbx")
+ if got := u.vaultPath.Text(); got != wantPath {
+ t.Fatalf("vaultPath = %q, want %q", got, wantPath)
+ }
+ if got := u.lifecycleMode; got != "local" {
+ t.Fatalf("lifecycleMode = %q, want local", got)
+ }
+ if !u.hasSelectedLifecycleTarget() {
+ t.Fatal("hasSelectedLifecycleTarget() = false, want true after import")
+ }
+ if _, err := os.Stat(wantPath); err != nil {
+ t.Fatalf("Stat(imported vault) error = %v", err)
+ }
+
+ reopened := newUIWithState("phone", &session.Manager{}, paths)
+ reopened.vaultPath.SetText(wantPath)
+ reopened.masterPassword.SetText(key.Password)
+ if err := reopened.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction(imported) error = %v", err)
+ }
+ reopened.state.NavigateToPath([]string{"Root", "Internet"})
+ reopened.filter()
+ if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
+ t.Fatalf("filteredTitles() = %v, want [Vault Console]", got)
+ }
+}
+
+func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ paths := statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"),
+ PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"),
+ }
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
+ Entries: []vault.Entry{
+ {ID: "entry-1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
+ },
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+ if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil {
+ t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err)
+ }
+ if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil {
+ t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err)
+ }
+
+ u := newUIWithState("phone", &session.Manager{}, paths)
+
+ wantPath := filepath.Join(dir, "crew-shared.kdbx")
+ if got := u.vaultPath.Text(); got != wantPath {
+ t.Fatalf("vaultPath = %q, want %q", got, wantPath)
+ }
+ if got := u.lifecycleMode; got != "local" {
+ t.Fatalf("lifecycleMode = %q, want local", got)
+ }
+ if !u.hasSelectedLifecycleTarget() {
+ t.Fatal("hasSelectedLifecycleTarget() = false, want true after pending import")
+ }
+ if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err)
+ }
+ if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", err)
+ }
+
+ reopened := newUIWithState("phone", &session.Manager{}, paths)
+ reopened.vaultPath.SetText(wantPath)
+ reopened.masterPassword.SetText(key.Password)
+ if err := reopened.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction(imported) error = %v", err)
+ }
+ reopened.state.NavigateToPath([]string{"Crew", "Internet"})
+ reopened.filter()
+ if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
+ t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
+ }
+}
+
+func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("phone", &session.Manager{})
+ u.vaultPath.SetText("/vaults/crew-shared.kdbx")
+
+ if got := u.currentShareableVaultPath(); got != "/vaults/crew-shared.kdbx" {
+ t.Fatalf("currentShareableVaultPath() = %q, want %q", got, "/vaults/crew-shared.kdbx")
+ }
+}
+
+func TestUIShareCurrentVaultActionSavesAndSharesCurrentVault(t *testing.T) {
+ t.Parallel()
+
+ session := &saveCaptureSession{}
+ sharer := &captureVaultSharer{}
+ u := newUIWithSession("phone", session)
+ u.vaultSharer = sharer
+ u.vaultPath.SetText("/vaults/crew-shared.kdbx")
+
+ if err := u.shareCurrentVaultAction(); err != nil {
+ t.Fatalf("shareCurrentVaultAction() error = %v", err)
+ }
+ if session.saveCount != 1 {
+ t.Fatalf("shareCurrentVaultAction() saveCount = %d, want 1", session.saveCount)
+ }
+ if got := sharer.path; got != "/vaults/crew-shared.kdbx" {
+ t.Fatalf("ShareVault path = %q, want %q", got, "/vaults/crew-shared.kdbx")
+ }
+ if got := sharer.title; got != "crew-shared.kdbx" {
+ t.Fatalf("ShareVault title = %q, want %q", got, "crew-shared.kdbx")
+ }
+}
+
+func TestUIShareCurrentVaultActionRequiresVaultPath(t *testing.T) {
+ t.Parallel()
+
+ u := newUIWithSession("phone", &saveCaptureSession{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
+ })
+ u.vaultSharer = &captureVaultSharer{}
+
+ err := u.shareCurrentVaultAction()
+ if err == nil || err.Error() != errVaultPathRequired {
+ t.Fatalf("shareCurrentVaultAction() error = %v, want %q", err, errVaultPathRequired)
+ }
}
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
@@ -4966,6 +7629,17 @@ func TestSupportsDesktopFilePicker(t *testing.T) {
}
}
+func TestSupportsSharedVaultImport(t *testing.T) {
+ t.Parallel()
+
+ if got := supportsSharedVaultImport("android"); !got {
+ t.Fatal("supportsSharedVaultImport(android) = false, want true")
+ }
+ if got := supportsSharedVaultImport("linux"); got {
+ t.Fatal("supportsSharedVaultImport(linux) = true, want false")
+ }
+}
+
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) {
t.Parallel()
@@ -5030,7 +7704,7 @@ func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) {
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
- {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
+ {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
@@ -5057,8 +7731,8 @@ func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) {
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
- {ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
- {ID: "bellagio", Title: "Bellagio", Password: "token-2", Path: []string{"Root", "Internet"}},
+ {ID: "vault-console", Title: "Vault Console", Password: "bellagio-pass-1", Path: []string{"Root", "Internet"}},
+ {ID: "bellagio", Title: "Bellagio", Password: "bellagio-pass-2", Path: []string{"Root", "Internet"}},
},
})
u.showEntriesSection()
@@ -5143,7 +7817,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test
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"},
},
@@ -5157,7 +7831,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test
want string
}{
{name: "username", target: clipboard.TargetUsername, label: "copy username", want: "dannyocean"},
- {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"},
+ {name: "password", target: clipboard.TargetPassword, label: "copy password", want: "bellagio-pass-1"},
{name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"},
}
@@ -5198,7 +7872,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(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"},
},
@@ -5215,7 +7889,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) {
if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() {
t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error())
}
- if strings.Contains(u.state.ErrorMessage, "token-1") {
+ if strings.Contains(u.state.ErrorMessage, "bellagio-pass-1") {
t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage)
}
if u.state.StatusMessage != "" {
@@ -5232,7 +7906,7 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(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"},
},
@@ -5250,8 +7924,8 @@ func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) {
}
generated := u.entryPassword.Text()
- if generated == "token-1" {
- t.Fatal("entryPassword.Text() = token-1, want a newly generated password")
+ if generated == "bellagio-pass-1" {
+ t.Fatal("entryPassword.Text() = bellagio-pass-1, want a newly generated password")
}
if len(generated) < passwords.DefaultProfiles()["strong"].Length {
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length)
@@ -5279,7 +7953,7 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
- Password: "token-1",
+ Password: "bellagio-pass-1",
Path: []string{"Root", "Internet"},
},
},
@@ -5289,13 +7963,13 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
u.filter()
u.state.SelectedEntryID = "vault-console"
- if got := u.detailPasswordValue(); got != "••••••••" {
- t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, "••••••••")
+ if got, want := u.detailPasswordValue(), strings.Repeat("•", len("bellagio-pass-1")); got != want {
+ t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, want)
}
u.showPassword = true
- if got := u.detailPasswordValue(); got != "token-1" {
- t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "token-1")
+ if got := u.detailPasswordValue(); got != "bellagio-pass-1" {
+ t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "bellagio-pass-1")
}
if err := u.lockAction(); err != nil {
@@ -5351,7 +8025,7 @@ func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(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"},
}); err != nil {
@@ -5600,18 +8274,18 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
- {ID: "lights", Title: "Home Assistant", Path: []string{"Crew", "codex"}},
+ {ID: "lights", Title: "Security Office", Path: []string{"Crew", "bashertarr"}},
},
})
- u.state.NavigateToPath([]string{"Crew", "codex"})
+ u.state.NavigateToPath([]string{"Crew", "bashertarr"})
u.filter()
u.state.SelectedEntryID = "lights"
if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
}
- if got := u.apiPolicyPath.Text(); got != "codex" {
- t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "codex")
+ if got := u.apiPolicyPath.Text(); got != "bashertarr" {
+ t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr")
}
if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl
similarity index 89%
rename from packaging/archlinux/keepassgo-git/PKGBUILD
rename to packaging/archlinux/keepassgo-git/PKGBUILD.tmpl
index 03e6578..358b845 100644
--- a/packaging/archlinux/keepassgo-git/PKGBUILD
+++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl
@@ -1,5 +1,5 @@
pkgname=keepassgo-git
-pkgver=r165.1c72a50
+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() {
diff --git a/ui_forms.go b/ui_forms.go
index a31f9f1..20f1264 100644
--- a/ui_forms.go
+++ b/ui_forms.go
@@ -21,7 +21,6 @@ import (
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,154 +30,13 @@ 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 {
@@ -200,6 +58,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}
return u.recentVaultList(gtx)
}),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
+ return layout.Dimensions{}
+ }
+ return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
+ return layout.Dimensions{}
+ }
+ return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
+ }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
@@ -296,29 +166,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- if u.lifecycleMode == "remote" {
- label := u.remoteOpenButtonLabel()
- return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
- layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- if busy {
- return passiveTonedButton(gtx, u.theme, label)
- }
- return tonedButton(gtx, u.theme, &u.openRemote, label)
- }),
- layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- if busy || !u.hasSelectedRemoteTarget() {
- return layout.Dimensions{}
- }
- return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
- }),
- layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- if busy || !u.hasSelectedRemoteTarget() {
- return layout.Dimensions{}
- }
- return u.selectedRemoteConnectionCard(gtx)
- }),
- )
- }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := "Open Vault"
@@ -361,58 +208,81 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
- record := u.currentRemoteRecord()
- lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
+ heading := u.selectedRemoteCardHeading()
+ primary := u.selectedRemoteCardPrimaryText()
+ details := u.selectedRemoteCardDetailLines()
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,
+ children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
+ lbl := material.Label(u.theme, unit.Sp(12), heading)
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 := material.Label(u.theme, unit.Sp(14), primary)
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))
+ }
+ for _, line := range details {
+ line := line
+ children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout))
+ children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ lbl := material.Label(u.theme, unit.Sp(11), line)
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)
- }),
+ }))
+ }
+ children = append(children,
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")
}),
)
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
})
}
+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)
+ 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 {
lastGroup := u.recentVaultGroup(path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -446,6 +316,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 +330,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."
}
@@ -617,11 +503,6 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
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{}
@@ -750,21 +631,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 {
diff --git a/vault/kdbx.go b/vault/kdbx.go
index ff63bda..b4e5850 100644
--- a/vault/kdbx.go
+++ b/vault/kdbx.go
@@ -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:
diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go
index f856c72..371edbf 100644
--- a/vault/kdbx_test.go
+++ b/vault/kdbx_test.go
@@ -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: "family-webdav",
+ Name: "Family Vault",
+ Backend: RemoteBackendWebDAV,
+ BaseURL: "https://dav.example.invalid/remote.php/dav",
+ Path: "files/family/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 != "family-webdav" || got.Name != "Family Vault" {
+ t.Fatalf("loaded remote profile = %#v, want family-webdav Family 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/family/keepass.kdbx" {
+ t.Fatalf("remote path = %q, want files/family/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"},
diff --git a/vault/model.go b/vault/model.go
index e2d12c9..31e171f 100644
--- a/vault/model.go
+++ b/vault/model.go
@@ -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 {