Compare commits
41 Commits
v0.1.0
..
c442a20d3e
| Author | SHA1 | Date | |
|---|---|---|---|
| c442a20d3e | |||
| cdf0c0c2c7 | |||
| 6ccff23804 | |||
| c3a9c0fddb | |||
| b593b1e6a7 | |||
| fe921b8790 | |||
| 7751b5472a | |||
| 07a071503a | |||
| b256a77d0c | |||
| 74d10535a1 | |||
| 16f603ccba | |||
| 9660369851 | |||
| 0a9201e0d1 | |||
| 74a2bbdc92 | |||
| 168927713c | |||
| 7a50138640 | |||
| d7741d14f5 | |||
| 5a98fe1a75 | |||
| 09e6425b1c | |||
| 4f9792d027 | |||
| 36c6687168 | |||
| 101a875837 | |||
| 81f1bcfca8 | |||
| b33f4905ab | |||
| edf0a9090d | |||
| 9b3f10f086 | |||
| cbfbe3be14 | |||
| f1f5d80ed8 | |||
| edac0f50a6 | |||
| 288cb34f1a | |||
| e88d1fd875 | |||
| a867ac4664 | |||
| 1aab5367a8 | |||
| 5d435f1f1f | |||
| 7868a77c8a | |||
| 43ef58936b | |||
| cb6fbd05a3 | |||
| 739d918c21 | |||
| 332ab58f58 | |||
| 8433d536f6 | |||
| 21b2e60df4 |
@@ -128,7 +128,7 @@ jobs:
|
||||
fi
|
||||
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
|
||||
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
|
||||
go build -ldflags "-X main.appVersion=${app_version}" -o "${out}" .
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
|
||||
done
|
||||
|
||||
- name: Build APK
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
build/
|
||||
*.apk
|
||||
/keepassgo
|
||||
android/keepassgo-android.jar
|
||||
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||
packaging/archlinux/keepassgo-git/pkg/
|
||||
packaging/archlinux/keepassgo-git/src/
|
||||
packaging/archlinux/keepassgo-git/keepassgo/
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
|
||||
@@ -95,6 +95,20 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||
- The stacked phone layout is the current preferred phone direction.
|
||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||
- Keep the product feeling like the same application on desktop, Android phone,
|
||||
and Android tablet.
|
||||
- Platform adaptation is allowed for layout and spacing, not for changing the
|
||||
user's mental model of the workflow.
|
||||
- Use the same action names, the same primary next steps, and the same workflow
|
||||
order across platforms unless there is a hard platform constraint.
|
||||
- Treat workflow prominence and reachability as product behavior, not visual
|
||||
polish. A feature is not parity-complete if it technically exists but is
|
||||
harder to discover or reach on one platform.
|
||||
- Prefer shared workflow decisions with platform-specific presentation, rather
|
||||
than platform-specific workflow branches.
|
||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||
and consistent with the product theme.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -104,6 +118,20 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
||||
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
|
||||
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
|
||||
- Treat the vault model as local-first across all platforms:
|
||||
every usable vault is a local KDBX file first, and remote sync attaches to
|
||||
that local vault rather than replacing it as the primary object.
|
||||
- Keep the remote-sync model standardized across desktop, Android phone, and
|
||||
Android tablet:
|
||||
shared remote configuration belongs in the vault,
|
||||
cross-platform workflow stays the same,
|
||||
and only Android's initial KDBX share/import transport may differ.
|
||||
- Do not persist remote credentials in plaintext app-local state.
|
||||
Keep only non-secret binding metadata outside the vault.
|
||||
- When working on remote-sync behavior, preserve the local cache / local-first
|
||||
design:
|
||||
opening or creating the local vault is the main workflow,
|
||||
and remote setup, remote settings, and remote use attach to that vault.
|
||||
|
||||
## Delivery Discipline
|
||||
|
||||
@@ -114,6 +142,14 @@ These features are product requirements, not “nice to have” ideas.
|
||||
implement the minimum code to satisfy them,
|
||||
verify with `go test ./...` and relevant lint checks,
|
||||
and commit each completed behavior.
|
||||
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
||||
just feature or label parity.
|
||||
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
||||
assert the same conceptual next step across desktop, phone, and tablet
|
||||
layouts.
|
||||
- When Android or phone UX is part of the slice, verify real reachability on an
|
||||
emulator or device for the exact flow being changed. Do not count “the same
|
||||
buttons exist somewhere on screen” as sufficient validation.
|
||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||
- If blocked, state the blocker concretely and stop only at that point.
|
||||
|
||||
@@ -122,6 +158,13 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Plan for direct KDBX support.
|
||||
- Plan for direct WebDAV-based workflows.
|
||||
- Avoid adding npm-based or browser-stack dependencies.
|
||||
- Keep remote configuration and synchronization local-first:
|
||||
the app should maintain a live local KDBX cache even when using a remote
|
||||
store, so remote outage does not eliminate vault access.
|
||||
- Prefer vault-backed remote setup and lookup over ad hoc local credential
|
||||
storage.
|
||||
- On Android, use system picker/share mechanisms for local vault import/export
|
||||
rather than raw path entry when a user is selecting or sharing a vault file.
|
||||
|
||||
## Tooling
|
||||
|
||||
@@ -144,6 +187,10 @@ These features are product requirements, not “nice to have” ideas.
|
||||
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the user’s real vault files whenever possible.
|
||||
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the user’s real config.
|
||||
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
|
||||
- For the Arch package, treat
|
||||
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
|
||||
as the source of truth and regenerate `PKGBUILD` from the template before
|
||||
building.
|
||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
|
||||
- Do not commit generated binaries.
|
||||
|
||||
@@ -29,12 +29,12 @@ Installed machine prerequisites expected by this repo:
|
||||
The repo tracks `gogio` as a Go tool, so the build runs through:
|
||||
|
||||
```sh
|
||||
go tool gogio -target android ...
|
||||
go tool gogio -target android ./cmd/keepassgo ...
|
||||
```
|
||||
|
||||
The Android build uses the branded icon asset at:
|
||||
|
||||
- `assets/keepassgo-icon.png`
|
||||
- `internal/assets/keepassgo-icon.png`
|
||||
|
||||
Note:
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ APP_ID ?= org.julianfamily.keepassgo
|
||||
APK_OUT ?= build/keepassgo.apk
|
||||
APK_VERSION ?= 0.1.0.1
|
||||
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
|
||||
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
|
||||
ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
SIGNPASS ?=
|
||||
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||
ARCH_REPO_DIR ?= $(CURDIR)
|
||||
|
||||
GOGIO_SIGN_FLAGS :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
@@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
apk: android/keepassgo-android.jar
|
||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||
@@ -42,8 +47,8 @@ apk: android/keepassgo-android.jar
|
||||
-version $(APK_VERSION) \
|
||||
-minsdk $(ANDROID_MIN_SDK) \
|
||||
-targetsdk $(ANDROID_TARGET_SDK) \
|
||||
-icon assets/keepassgo-icon.png \
|
||||
.
|
||||
-icon internal/assets/keepassgo-icon.png \
|
||||
./cmd/keepassgo
|
||||
|
||||
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||
@@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
-d "$$tmpdir" \
|
||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||
|
||||
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||
@sed \
|
||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||
|
||||
@@ -38,14 +38,14 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
|
||||
Desktop build:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go build ./cmd/keepassgo
|
||||
```
|
||||
|
||||
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
|
||||
You can override the version shown in KeePassGO with:
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X main.appVersion=v0.0.1" ./...
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
|
||||
```
|
||||
|
||||
## Arch Linux Package
|
||||
@@ -89,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest
|
||||
Package:
|
||||
|
||||
```bash
|
||||
go tool gogio -target android -icon assets/keepassgo-icon.png .
|
||||
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
|
||||
```
|
||||
|
||||
You will need the Android SDK and NDK installed and configured for real device or release packaging.
|
||||
|
||||
@@ -11,6 +11,36 @@ The product is not complete until the global exit criteria at the end of this fi
|
||||
These items came from a hands-on emulator and desktop walkthrough.
|
||||
They should be treated as usability work, not just polish.
|
||||
|
||||
### Cross-Platform Workflow Parity
|
||||
|
||||
These items are required to keep desktop, Android phone, and Android tablet
|
||||
feeling like the same application rather than three related UIs.
|
||||
|
||||
- Workflow parity:
|
||||
define canonical workflows for open, unlock, set up remote sync, use remote
|
||||
sync, browse entries, and edit entries.
|
||||
- Workflow parity:
|
||||
ensure desktop, phone, and tablet use the same action names and the same
|
||||
primary next steps for those workflows.
|
||||
- Workflow parity:
|
||||
remove or reduce platform-specific workflow exceptions where the same user
|
||||
intent currently takes a different route on different form factors.
|
||||
- Testing:
|
||||
add cross-mode behavior tests that assert workflow order and action
|
||||
prominence, not just label presence.
|
||||
- Testing:
|
||||
add explicit lifecycle/open-screen tests for reachability of the primary
|
||||
action on desktop, phone, and tablet layouts.
|
||||
- Testing:
|
||||
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
||||
removal are reachable from the same primary affordance family across modes.
|
||||
- Android verification:
|
||||
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
||||
including with the on-screen keyboard visible.
|
||||
- Android verification:
|
||||
treat “present but below the fold or behind an unexpected branch” as a parity
|
||||
failure, not as acceptable platform variation.
|
||||
|
||||
### Primary Workflow Changes
|
||||
|
||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||
|
||||
@@ -22,3 +22,26 @@
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/keepassgo_accessibility_service" />
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||
android:authorities="org.julianfamily.keepassgo.share"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<activity
|
||||
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/x-keepass2" />
|
||||
<data android:mimeType="application/vnd.keepass" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public final class AndroidShare {
|
||||
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
||||
|
||||
private AndroidShare() {
|
||||
}
|
||||
|
||||
public static void shareVault(Context context, String path, String title) throws IOException {
|
||||
File source = new File(path);
|
||||
if (!source.isFile()) {
|
||||
throw new IOException("vault file not found: " + path);
|
||||
}
|
||||
File shared = copyToSharedExport(context, source);
|
||||
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
||||
|
||||
Intent send = new Intent(Intent.ACTION_SEND);
|
||||
send.setType("application/x-keepass2");
|
||||
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
Intent chooser = Intent.createChooser(send, "Share vault");
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
context.startActivity(chooser);
|
||||
}
|
||||
|
||||
static File sharedDirectory(Context context) {
|
||||
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
||||
}
|
||||
|
||||
private static File copyToSharedExport(Context context, File source) throws IOException {
|
||||
File dir = sharedDirectory(context);
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File target = new File(dir, sanitizeFilename(source.getName()));
|
||||
try (FileInputStream in = new FileInputStream(source);
|
||||
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
String trimmed = name == null ? "" : name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
if (trimmed.endsWith(".kdbx")) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed + ".kdbx";
|
||||
}
|
||||
|
||||
private static String sanitizeTitle(String title, String fallbackName) {
|
||||
String trimmed = title == null ? "" : title.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed;
|
||||
}
|
||||
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
||||
if (!fallback.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class SharedVaultImportActivity extends Activity {
|
||||
private static final String TAG = "KeePassGOImport";
|
||||
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
handleIntent(getIntent());
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleIntent(intent);
|
||||
launchMainActivity();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void handleIntent(Intent intent) {
|
||||
Uri uri = resolveSharedUri(intent);
|
||||
if (uri == null) {
|
||||
Log.i(TAG, "no shared vault URI on intent");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
persistPendingImport(uri);
|
||||
Log.i(TAG, "queued shared vault import from " + uri);
|
||||
} catch (IOException | RuntimeException err) {
|
||||
Log.e(TAG, "failed to queue shared vault import", err);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri resolveSharedUri(Intent intent) {
|
||||
if (intent == null) {
|
||||
return null;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_SEND.equals(action)) {
|
||||
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
return intent.getData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistPendingImport(Uri uri) throws IOException {
|
||||
File dir = new File(getFilesDir(), "keepassgo");
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||
}
|
||||
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
throw new IOException("failed to open shared vault stream");
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) >= 0) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveDisplayName(Uri uri) {
|
||||
String displayName = queryDisplayName(uri);
|
||||
if (!displayName.isEmpty()) {
|
||||
return displayName;
|
||||
}
|
||||
String lastSegment = uri.getLastPathSegment();
|
||||
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||
return lastSegment.trim();
|
||||
}
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
private String queryDisplayName(Uri uri) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index >= 0) {
|
||||
String value = cursor.getString(index);
|
||||
if (value != null) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException err) {
|
||||
Log.w(TAG, "failed to query display name", err);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void launchMainActivity() {
|
||||
Intent launch = new Intent();
|
||||
launch.setClassName(this, "org.gioui.GioActivity");
|
||||
startActivity(launch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.julianfamily.keepassgo;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
public final class SharedVaultProvider extends ContentProvider {
|
||||
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||
File file = resolveSharedFile(uri);
|
||||
String[] columns = projection;
|
||||
if (columns == null || columns.length == 0) {
|
||||
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
Object[] row = new Object[columns.length];
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
switch (columns[i]) {
|
||||
case OpenableColumns.DISPLAY_NAME:
|
||||
row[i] = file.getName();
|
||||
break;
|
||||
case OpenableColumns.SIZE:
|
||||
row[i] = file.length();
|
||||
break;
|
||||
default:
|
||||
row[i] = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor.addRow(row);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return "application/x-keepass2";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("insert is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("delete is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("update is not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
File file = resolveSharedFile(uri);
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
static Uri uriForFile(String name) {
|
||||
return new Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(name)
|
||||
.build();
|
||||
}
|
||||
|
||||
private File resolveSharedFile(Uri uri) {
|
||||
if (getContext() == null) {
|
||||
throw new IllegalStateException("provider context is unavailable");
|
||||
}
|
||||
String name = sanitizeFilename(uri.getLastPathSegment());
|
||||
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
||||
}
|
||||
|
||||
private static String sanitizeFilename(String name) {
|
||||
if (name == null) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
String trimmed = name.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "shared-vault.kdbx";
|
||||
}
|
||||
return new File(trimmed).getName();
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,10 @@ const (
|
||||
DefaultAppID = "org.julianfamily.keepassgo"
|
||||
DefaultAPKOut = "build/keepassgo.apk"
|
||||
DefaultVersion = "0.1.0.1"
|
||||
DefaultLdflags = "-X main.appVersion=dev"
|
||||
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
|
||||
DefaultMinSDK = "28"
|
||||
DefaultTargetSDK = "35"
|
||||
DefaultIconPath = "assets/keepassgo-icon.png"
|
||||
DefaultIconPath = "internal/assets/keepassgo-icon.png"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -58,7 +58,7 @@ func (c Config) GogioArgs() []string {
|
||||
"-minsdk", c.MinSDK,
|
||||
"-targetsdk", c.TargetSDK,
|
||||
"-icon", c.IconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
|
||||
"-minsdk", DefaultMinSDK,
|
||||
"-targetsdk", DefaultTargetSDK,
|
||||
"-icon", DefaultIconPath,
|
||||
".",
|
||||
"./cmd/keepassgo",
|
||||
}
|
||||
|
||||
if got := cfg.GogioArgs(); !slices.Equal(got, want) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appui"
|
||||
|
||||
func main() {
|
||||
appui.Main()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Local-First Remote Sync Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||
|
||||
- every vault is a local KDBX file first
|
||||
- remote sync is an optional binding on top of that local file
|
||||
- shared remote configuration lives in the vault
|
||||
- user-specific remote credentials live in the vault
|
||||
- app-local state stores only non-secret binding metadata
|
||||
|
||||
Android adds only one platform-specific capability on top of that model:
|
||||
|
||||
- share/import the initial local KDBX file between devices
|
||||
|
||||
## Product Rules
|
||||
|
||||
1. A remote-backed vault must always have a local cache KDBX file.
|
||||
2. Opening a remote-backed vault should open the local KDBX first.
|
||||
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||
4. Remote credentials must not be stored in plaintext app-local state.
|
||||
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||
7. Sync must support both manual and automatic modes.
|
||||
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||
|
||||
## Target Data Model
|
||||
|
||||
### In-Vault Shared Remote Profile
|
||||
|
||||
Store a reusable remote profile in the vault with fields such as:
|
||||
|
||||
- profile ID
|
||||
- profile name
|
||||
- backend type, initially WebDAV
|
||||
- base URL
|
||||
- remote object path
|
||||
- optional notes or labels
|
||||
- default sync policy, if shared defaults are desirable
|
||||
|
||||
### In-Vault User Credential Binding
|
||||
|
||||
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||
|
||||
- remote profile ID
|
||||
- credential entry UUID, or another stable internal reference
|
||||
- optional username field override if needed
|
||||
|
||||
The credential entry should contain the actual username/password or token.
|
||||
|
||||
### Local App State
|
||||
|
||||
Persist only non-secret binding state such as:
|
||||
|
||||
- local vault path
|
||||
- selected remote profile ID
|
||||
- selected credential entry reference
|
||||
- sync policy override
|
||||
- last sync metadata
|
||||
- conflict or recovery markers
|
||||
|
||||
## Core Flows
|
||||
|
||||
### Create Or Configure Remote Sync
|
||||
|
||||
1. Open or create a local vault.
|
||||
2. Create or edit a shared remote profile in that vault.
|
||||
3. Create or select a credential entry in that vault.
|
||||
4. Bind the local vault to the selected remote profile and credential reference.
|
||||
5. Choose manual or automatic sync behavior.
|
||||
|
||||
### Reopen Existing Remote-Backed Vault
|
||||
|
||||
1. Open the local vault file from app state.
|
||||
2. Resolve the selected remote profile from vault contents.
|
||||
3. Resolve the credential entry from vault contents.
|
||||
4. Offer or perform sync based on the binding policy.
|
||||
|
||||
### Bootstrap A New Android Device
|
||||
|
||||
1. Share the local KDBX file through Android Sharesheet.
|
||||
2. Import and open that KDBX locally on the new device.
|
||||
3. Select a remote profile stored in the vault.
|
||||
4. Select or create that user’s credential entry in the vault.
|
||||
5. Bind the local vault as the cache for the remote-backed setup.
|
||||
|
||||
## Migration Requirements
|
||||
|
||||
1. Migrate existing remote connections that save credentials in app state.
|
||||
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||
3. Replace saved plaintext credential state with a vault credential reference.
|
||||
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Domain Model
|
||||
|
||||
- define remote profile structures independent of Gio UI
|
||||
- define credential reference structures independent of Gio UI
|
||||
- define sync binding state independent of Gio UI
|
||||
- add behavior tests for local-first remote-backed vaults
|
||||
|
||||
### Phase 2: Vault Storage
|
||||
|
||||
- persist remote profiles in the vault
|
||||
- persist credential references in the vault
|
||||
- resolve credentials from normal vault entries
|
||||
- add behavior tests for read/write and lookup semantics
|
||||
|
||||
### Phase 3: State And Open Flow
|
||||
|
||||
- shrink app state to non-secret metadata only
|
||||
- update open flows to always prefer the local cache vault
|
||||
- update reopen behavior on all platforms to use the same model
|
||||
- add migration coverage for old remote state
|
||||
|
||||
### Phase 4: Sync Binding
|
||||
|
||||
- bind a local vault to a selected remote profile
|
||||
- support manual sync
|
||||
- support automatic sync on open/save
|
||||
- define conflict and remote-failure handling for the local cache model
|
||||
|
||||
### Phase 5: Android Bootstrap
|
||||
|
||||
- add Android Sharesheet export of the current local KDBX
|
||||
- add Android import flow for a shared KDBX
|
||||
- keep the remote pivot flow consistent with desktop after local open
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||
4. How should multiple remote profiles per vault be presented in the UI?
|
||||
5. What should happen when the credential entry reference no longer resolves?
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
Implement the shared domain model and tests first:
|
||||
|
||||
- model a local vault plus optional remote binding
|
||||
- define in-vault remote profile and credential reference semantics
|
||||
- add tests proving app state no longer needs plaintext remote credentials
|
||||
|
||||
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||
@@ -2,8 +2,6 @@ module git.julianfamily.org/keepassgo
|
||||
|
||||
go 1.26
|
||||
|
||||
replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
gioui.org/x v0.8.0
|
||||
|
||||
@@ -39,6 +39,8 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
|
||||
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
|
||||
@@ -7,26 +7,26 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type DirtyProvider func() bool
|
||||
|
||||
type Host struct {
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
server *Server
|
||||
grpcServer *grpc.Server
|
||||
listener net.Listener
|
||||
lifecycle lifecycleBackend
|
||||
dirty DirtyProvider
|
||||
mu sync.Mutex
|
||||
lastModel vault.Model
|
||||
started bool
|
||||
listenAddr string
|
||||
}
|
||||
|
||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -1053,64 +1053,64 @@ func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry
|
||||
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
t.Helper()
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
}
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{
|
||||
ID: "website-login",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return newTestClientForModel(t, model)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,20 +22,20 @@ var (
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
OutcomeAllowOnce Outcome = "allow-once"
|
||||
OutcomeDenyOnce Outcome = "deny-once"
|
||||
OutcomeAllowPermanent Outcome = "allow-permanent"
|
||||
OutcomeDenyPermanent Outcome = "deny-permanent"
|
||||
OutcomeCancel Outcome = "cancel"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
ID string
|
||||
TokenID string
|
||||
TokenName string
|
||||
ClientName string
|
||||
Operation apitokens.Operation
|
||||
Resource apitokens.Resource
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||
@@ -0,0 +1,141 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type SyncMode string
|
||||
|
||||
const (
|
||||
SyncModeManual SyncMode = "manual"
|
||||
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||
)
|
||||
|
||||
type RemoteBinding struct {
|
||||
LocalVaultPath string `json:"localVaultPath"`
|
||||
RemoteProfileID string `json:"remoteProfileId"`
|
||||
CredentialEntryID string `json:"credentialEntryId"`
|
||||
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||
}
|
||||
|
||||
type ResolvedRemoteBinding struct {
|
||||
Profile vault.RemoteProfile
|
||||
Credentials vault.Entry
|
||||
}
|
||||
|
||||
type RemoteBindingInput struct {
|
||||
LocalVaultPath string
|
||||
RemoteProfileID string
|
||||
RemoteProfileName string
|
||||
BaseURL string
|
||||
RemotePath string
|
||||
CredentialEntryID string
|
||||
CredentialTitle string
|
||||
Username string
|
||||
Password string
|
||||
CredentialPath []string
|
||||
SyncMode SyncMode
|
||||
}
|
||||
|
||||
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||
}
|
||||
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||
if err != nil {
|
||||
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||
}
|
||||
return ResolvedRemoteBinding{
|
||||
Profile: profile,
|
||||
Credentials: credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
||||
if model == nil {
|
||||
return RemoteBinding{}, fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
||||
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
||||
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
||||
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
||||
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
||||
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
||||
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
|
||||
switch {
|
||||
case input.LocalVaultPath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
||||
case input.RemoteProfileID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
||||
case input.BaseURL == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
||||
case input.RemotePath == "":
|
||||
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
||||
case input.CredentialEntryID == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
||||
case input.Password == "":
|
||||
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
||||
}
|
||||
|
||||
if input.RemoteProfileName == "" {
|
||||
input.RemoteProfileName = input.RemoteProfileID
|
||||
}
|
||||
if input.CredentialTitle == "" {
|
||||
input.CredentialTitle = "Remote Sign-In"
|
||||
}
|
||||
|
||||
model.UpsertRemoteProfile(vault.RemoteProfile{
|
||||
ID: input.RemoteProfileID,
|
||||
Name: input.RemoteProfileName,
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: input.BaseURL,
|
||||
Path: input.RemotePath,
|
||||
})
|
||||
model.UpsertEntry(vault.Entry{
|
||||
ID: input.CredentialEntryID,
|
||||
Title: input.CredentialTitle,
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
URL: input.BaseURL,
|
||||
Path: append([]string(nil), input.CredentialPath...),
|
||||
})
|
||||
|
||||
return RemoteBinding{
|
||||
LocalVaultPath: input.LocalVaultPath,
|
||||
RemoteProfileID: input.RemoteProfileID,
|
||||
CredentialEntryID: input.CredentialEntryID,
|
||||
SyncMode: normalizeSyncMode(input.SyncMode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
||||
return fmt.Errorf("remote profile id is required")
|
||||
}
|
||||
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
||||
return fmt.Errorf("credential entry id is required")
|
||||
}
|
||||
|
||||
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
||||
model.RemoveEntryByID(binding.CredentialEntryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSyncMode(mode SyncMode) SyncMode {
|
||||
switch mode {
|
||||
case SyncModeAutomaticOnOpenSave:
|
||||
return SyncModeAutomaticOnOpenSave
|
||||
default:
|
||||
return SyncModeManual
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "linuscaldwell-webdav",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
binding := RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "linuscaldwell-webdav",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||
}
|
||||
|
||||
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}}
|
||||
|
||||
_, err = (RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "missing-creds",
|
||||
}).Resolve(model)
|
||||
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := json.Marshal(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||
}
|
||||
|
||||
text := string(content)
|
||||
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
||||
if strings.Contains(text, disallowed) {
|
||||
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if len(model.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
||||
}
|
||||
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
|
||||
credentials, err := model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||
}
|
||||
|
||||
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
|
||||
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
|
||||
}
|
||||
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input RemoteBindingInput
|
||||
}{
|
||||
{
|
||||
name: "missing_local_vault_path",
|
||||
input: RemoteBindingInput{
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_remote_base_url",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing_credential_entry_id",
|
||||
input: RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
Password: "bellagio-pass-1",
|
||||
},
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var model vault.Model
|
||||
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
||||
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}
|
||||
|
||||
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if got := len(model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
type Section string
|
||||
@@ -133,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
||||
return apitokens.Entries(model)
|
||||
}
|
||||
|
||||
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := slices.Clone(model.RemoteProfiles)
|
||||
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||
switch {
|
||||
case a.Name < b.Name:
|
||||
return -1
|
||||
case a.Name > b.Name:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := slices.Clone(model.Entries)
|
||||
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
case a.ID < b.ID:
|
||||
return -1
|
||||
case a.ID > b.ID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||
model, err := s.currentModel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := binding.Resolve(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := webdav.Client{
|
||||
BaseURL: resolved.Profile.BaseURL,
|
||||
Username: resolved.Credentials.Username,
|
||||
Password: resolved.Credentials.Password,
|
||||
}
|
||||
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||
}
|
||||
|
||||
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
binding, err := ConfigureRemoteBinding(&model, input)
|
||||
if err != nil {
|
||||
return RemoteBinding{}, err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) CreateGroup(name string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/session"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
||||
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
{
|
||||
ID: "archive-webdav",
|
||||
Name: "Archive Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/archive.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteProfiles() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
|
||||
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.RemoteCredentialEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteCredentialEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
|
||||
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -187,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
||||
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -350,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -419,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
},
|
||||
@@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
@@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||
}
|
||||
|
||||
@@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
},
|
||||
},
|
||||
RemoteProfiles: []vault.RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
|
||||
}
|
||||
|
||||
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||
}
|
||||
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
||||
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
||||
}
|
||||
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
|
||||
}
|
||||
if len(state.CurrentPath) != 0 {
|
||||
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||
}
|
||||
if state.SelectedEntryID != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||
}
|
||||
if state.Dirty {
|
||||
t.Fatal("Dirty = true, want false after bound remote open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &lifecycleStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "missing-profile",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{}}
|
||||
state := State{Session: sess}
|
||||
|
||||
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
RemoteProfileName: "Bellagio Vault",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
CredentialPath: []string{"Crew", "Internet"},
|
||||
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||
}
|
||||
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
|
||||
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||
}
|
||||
credentials, err := sess.model.EntryByID("remote-creds-1")
|
||||
if err != nil {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||
}
|
||||
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{Session: stubSession{model: vault.Model{}}}
|
||||
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
RemotePath: "files/bellagio/keepass.kdbx",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
Password: "bellagio-pass-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{{
|
||||
ID: "remote-creds-1",
|
||||
Title: "Bellagio WebDAV Sign-In",
|
||||
Username: "linuscaldwell",
|
||||
Password: "bellagio-pass-1",
|
||||
}},
|
||||
RemoteProfiles: []vault.RemoteProfile{{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: vault.RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
}},
|
||||
}}
|
||||
state := State{Session: sess}
|
||||
|
||||
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||
LocalVaultPath: "/tmp/bellagio.kdbx",
|
||||
RemoteProfileID: "bellagio-webdav",
|
||||
CredentialEntryID: "remote-creds-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
|
||||
}
|
||||
if got := len(sess.model.RemoteProfiles); got != 0 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||
}
|
||||
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
SelectedEntryID: "vault-console",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||
@@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||
@@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
SelectedEntryID: "bellagio",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
@@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error {
|
||||
}
|
||||
|
||||
type lifecycleStubSession struct {
|
||||
createCalls int
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
createCalls int
|
||||
model vault.Model
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remoteClient webdav.Client
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||
return vault.Model{}, nil
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
||||
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||
s.remoteClient = client
|
||||
s.remotePath = path
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package actions
|
||||
|
||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
||||
|
||||
type SyncMenuModel struct {
|
||||
HasOpenVault bool
|
||||
HasSelectedBinding bool
|
||||
ShowSelectors bool
|
||||
ShowShare bool
|
||||
ShowSaveCurrentBinding bool
|
||||
SavedBindingSummary SyncMenuBindingSummary
|
||||
RemoteBaseURL string
|
||||
RemotePath string
|
||||
RemoteUsername string
|
||||
RemotePassword string
|
||||
SelectedVaultSyncMode appstate.SyncMode
|
||||
}
|
||||
|
||||
type SyncMenuBindingSummary struct {
|
||||
ProfileLabel string
|
||||
CredentialLabel string
|
||||
SyncLabel string
|
||||
OK bool
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) SavedBindingHeading() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use this vault's saved remote sync target"
|
||||
}
|
||||
return "Use a saved remote profile from this vault"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) OpenSelectedButtonLabel() string {
|
||||
if !m.ShowSelectors {
|
||||
return "Use Remote Sync"
|
||||
}
|
||||
return "Open Saved Remote"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string {
|
||||
return "Use Remote Sync"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
||||
return m.HasOpenVault && m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string {
|
||||
return "Remote Sync Settings"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool {
|
||||
return m.ShowRemoteSyncSettingsShortcut()
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string {
|
||||
return "Stop Using Remote Sync"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool {
|
||||
return m.HasOpenVault && !m.HasSelectedBinding
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string {
|
||||
return "Set Up Remote Sync"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) ActionLabels() []string {
|
||||
labels := []string{"Open Advanced Sync"}
|
||||
if m.ShowRemoteSyncSetupShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
||||
}
|
||||
if m.ShowDirectRemoteSyncShortcut() {
|
||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoteSyncSettingsShortcut() {
|
||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
||||
}
|
||||
if m.ShowRemoveRemoteSyncShortcut() {
|
||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string {
|
||||
return "Bind this local vault to the current remote target"
|
||||
}
|
||||
|
||||
func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string {
|
||||
return "Save Remote In Vault"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
func AnchoredMenuX(triggerWidth, menuWidth int) int {
|
||||
return triggerWidth - menuWidth
|
||||
}
|
||||
|
||||
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
|
||||
x := rowOriginX + triggerRightX - menuWidth
|
||||
if x < 0 {
|
||||
return 0
|
||||
}
|
||||
if x+menuWidth > containerWidth {
|
||||
return max(0, containerWidth-menuWidth)
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
type DropdownAnchor struct {
|
||||
TriggerRightX int
|
||||
TriggerBottomY int
|
||||
}
|
||||
|
||||
func (a DropdownAnchor) Point() image.Point {
|
||||
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
|
||||
}
|
||||
|
||||
type DropdownSurface struct {
|
||||
ContainerWidth int
|
||||
LeftInset int
|
||||
TopInset int
|
||||
}
|
||||
|
||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
||||
menuGTX := gtx
|
||||
menuGTX.Constraints.Min = image.Point{}
|
||||
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
|
||||
return menuGTX
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
|
||||
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
|
||||
y := s.TopInset + anchor.TriggerBottomY
|
||||
return image.Pt(x, y)
|
||||
}
|
||||
|
||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
||||
menuGTX := s.MenuConstraints(gtx)
|
||||
menuOps := op.Record(gtx.Ops)
|
||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
||||
menuCall := menuOps.Stop()
|
||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
||||
stack := op.Offset(menuOrigin).Push(gtx.Ops)
|
||||
menuCall.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
type HeaderActionMetrics struct {
|
||||
RowOriginX int
|
||||
Spacing int
|
||||
RowDims layout.Dimensions
|
||||
SyncDims layout.Dimensions
|
||||
LockDims layout.Dimensions
|
||||
MainDims layout.Dimensions
|
||||
}
|
||||
|
||||
func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor {
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
|
||||
func (m HeaderActionMetrics) MainAnchor() DropdownAnchor {
|
||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
||||
TriggerBottomY: m.RowDims.Size.Y,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//go:build android
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Werror
|
||||
#cgo LDFLAGS: -landroid
|
||||
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
||||
return (*env)->GetObjectClass(env, obj);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||
}
|
||||
|
||||
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
||||
return (*env)->CallObjectMethodA(env, obj, method, args);
|
||||
}
|
||||
|
||||
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
||||
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
||||
}
|
||||
|
||||
static jvalue jni_ValueObject(jobject obj) {
|
||||
jvalue value;
|
||||
value.l = obj;
|
||||
return value;
|
||||
}
|
||||
|
||||
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
||||
return (*env)->ExceptionOccurred(env);
|
||||
}
|
||||
|
||||
static void jni_ExceptionClear(JNIEnv *env) {
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
||||
return (*env)->NewString(env, unicodeChars, len);
|
||||
}
|
||||
|
||||
static int jni_IsNull(jobject obj) {
|
||||
return obj == NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"gioui.org/app"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type androidVaultSharer struct{}
|
||||
|
||||
//go:linkname gioJavaVM gioui.org/app.javaVM
|
||||
func gioJavaVM() *C.JavaVM
|
||||
|
||||
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return androidVaultSharer{}
|
||||
}
|
||||
|
||||
func (androidVaultSharer) ShareVault(path, title string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("vault path is required")
|
||||
}
|
||||
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
||||
if C.jni_IsNull(ctx) != 0 {
|
||||
return fmt.Errorf("android app context is not available")
|
||||
}
|
||||
var callErr error
|
||||
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
||||
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
||||
if err != nil {
|
||||
callErr = err
|
||||
return
|
||||
}
|
||||
methodName := cString("shareVault")
|
||||
defer C.free(unsafe.Pointer(methodName))
|
||||
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
||||
defer C.free(unsafe.Pointer(methodSig))
|
||||
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
||||
if method == nil {
|
||||
callErr = androidJNIError(env, "resolve shareVault method")
|
||||
if callErr == nil {
|
||||
callErr = fmt.Errorf("resolve shareVault method")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jPath := androidJavaString(env, path)
|
||||
jTitle := androidJavaString(env, title)
|
||||
args := [3]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(ctx)
|
||||
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
||||
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
||||
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
||||
callErr = androidJNIError(env, "share vault")
|
||||
})
|
||||
return callErr
|
||||
}
|
||||
|
||||
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
||||
var zeroClass C.jclass
|
||||
contextClass := C.jni_GetObjectClass(env, ctx)
|
||||
getClassLoaderName := cString("getClassLoader")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderName))
|
||||
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
||||
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
||||
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
||||
if getClassLoader == nil {
|
||||
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
||||
}
|
||||
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
||||
if err := androidJNIError(env, "load class loader"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(classLoader) != 0 {
|
||||
return zeroClass, fmt.Errorf("android class loader is nil")
|
||||
}
|
||||
|
||||
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
||||
loadClassName := cString("loadClass")
|
||||
defer C.free(unsafe.Pointer(loadClassName))
|
||||
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
||||
defer C.free(unsafe.Pointer(loadClassSig))
|
||||
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
||||
if loadClass == nil {
|
||||
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
return zeroClass, fmt.Errorf("resolve loadClass")
|
||||
}
|
||||
|
||||
jClassName := androidJavaString(env, name)
|
||||
args := [1]C.jvalue{}
|
||||
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
||||
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
||||
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
||||
return zeroClass, err
|
||||
}
|
||||
if C.jni_IsNull(loaded) != 0 {
|
||||
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
||||
}
|
||||
return C.jclass(loaded), nil
|
||||
}
|
||||
|
||||
func androidJNIError(env *C.JNIEnv, action string) error {
|
||||
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
||||
C.jni_ExceptionClear(env)
|
||||
return fmt.Errorf("%s: Java exception", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
||||
chars := utf16.Encode([]rune(s))
|
||||
if len(chars) == 0 {
|
||||
return C.jni_NewString(env, nil, 0)
|
||||
}
|
||||
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
||||
}
|
||||
|
||||
func cString(value string) *C.char {
|
||||
return C.CString(value)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !android
|
||||
|
||||
package platform
|
||||
|
||||
func NewVaultSharer(goos string) VaultSharer {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -8,23 +8,27 @@ import (
|
||||
gioclipboard "gioui.org/io/clipboard"
|
||||
"gioui.org/layout"
|
||||
|
||||
appclipboard "git.julianfamily.org/keepassgo/clipboard"
|
||||
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
)
|
||||
|
||||
type VaultSharer interface {
|
||||
ShareVault(path, title string) error
|
||||
}
|
||||
|
||||
type clipboardCommandWriter struct {
|
||||
mu sync.Mutex
|
||||
pending []string
|
||||
invalidate func()
|
||||
}
|
||||
|
||||
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
|
||||
if strings.EqualFold(goos, "android") {
|
||||
return &clipboardCommandWriter{invalidate: invalidate}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
|
||||
commandWriter, ok := writer.(*clipboardCommandWriter)
|
||||
if !ok {
|
||||
return
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package platform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := newPlatformClipboardWriter("android", nil)
|
||||
writer := NewClipboardWriter("android", nil)
|
||||
if _, ok := writer.(*clipboardCommandWriter); !ok {
|
||||
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
|
||||
if writer := NewClipboardWriter("linux", nil); writer != nil {
|
||||
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
)
|
||||
|
||||
func apiOperations() []apitokens.Operation {
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
)
|
||||
|
||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode != "phone" {
|
||||
if !u.usesCompactViewport() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
||||
}
|
||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func (u *ui) attachmentInput() (string, []byte, error) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,13 +15,12 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
)
|
||||
|
||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
busy := u.lifecycleBusy()
|
||||
showLocalChooser := u.showLocalVaultChooser()
|
||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -31,206 +30,14 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
||||
if u.lifecycleMode == "remote" {
|
||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
||||
}
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote")
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleMode == "remote" {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if showRemoteChooser && !busy {
|
||||
return u.recentRemoteList(gtx)
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
||||
box.Color = accentColor
|
||||
return box.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showRemoteChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.recentVaultList(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
switch {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}),
|
||||
)
|
||||
return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -249,168 +56,232 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser || busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.recentVaultList(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.lifecycleAdvancedDisclosure(gtx)
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions {
|
||||
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
switch {
|
||||
case busy:
|
||||
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
|
||||
case selectedLocalPath == "":
|
||||
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
if u.shouldPrioritizeLifecyclePrimaryActions() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
if busy {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(u.lifecycleAdvancedCard),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, "Opening Vault...")
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || u.lifecycleAdvancedHidden {
|
||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.lifecycleMode == "remote" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||
}),
|
||||
)
|
||||
})
|
||||
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.lifecycleMode == "remote" {
|
||||
label := u.remoteOpenButtonLabel()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, label)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || !u.hasSelectedRemoteTarget() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.selectedRemoteConnectionCard(gtx)
|
||||
}),
|
||||
)
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Open Vault"
|
||||
if busy {
|
||||
label = "Opening Vault..."
|
||||
}
|
||||
if busy {
|
||||
return passiveTonedButton(gtx, u.theme, label)
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy {
|
||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||
}
|
||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||
func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
|
||||
if busy || selectedLocalPath == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
|
||||
return u.usesCompactViewport()
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardHeading() string {
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
return "CACHED VAULT"
|
||||
}
|
||||
return "SELECTED CONNECTION"
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardPrimaryText() string {
|
||||
record := u.currentRemoteRecord()
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
path := strings.TrimSpace(u.vaultPath.Text())
|
||||
if label := friendlyRecentVaultLabel(path); label != "" {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return friendlyRecentRemoteLabel(record)
|
||||
}
|
||||
|
||||
func (u *ui) selectedRemoteCardDetailLines() []string {
|
||||
record := u.currentRemoteRecord()
|
||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
Password: u.remotePassword.Text(),
|
||||
}))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(lastGroup) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
lines := make([]string, 0, 3)
|
||||
if u.selectedRemoteUsesLocalCache() {
|
||||
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
||||
lines = append(lines, dir)
|
||||
}
|
||||
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
||||
} else {
|
||||
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
||||
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
||||
}
|
||||
if len(lastGroup) > 0 {
|
||||
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
||||
@@ -446,6 +317,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 +331,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string {
|
||||
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
|
||||
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
|
||||
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
|
||||
return summary + " · Syncs automatically on open and save."
|
||||
}
|
||||
return summary + " · Sync manually when you choose Use Remote Sync."
|
||||
}
|
||||
return "Open this vault to set up a WebDAV sync target for it."
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
||||
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
||||
}
|
||||
@@ -570,76 +457,6 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.recentRemotes) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
||||
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
maxY := gtx.Dp(unit.Dp(180))
|
||||
if gtx.Constraints.Max.Y > maxY {
|
||||
gtx.Constraints.Max.Y = maxY
|
||||
}
|
||||
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
|
||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
||||
}
|
||||
return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
record := u.recentRemotes[i]
|
||||
label := friendlyRecentRemoteLabel(record)
|
||||
selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.recentRemoteClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(record.LastGroup) == 0 {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / "))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions {
|
||||
if !selected {
|
||||
return compactCard(gtx, w)
|
||||
@@ -750,21 +567,6 @@ func normalizedRemoteHost(baseURL string) string {
|
||||
return strings.TrimSuffix(host, "/")
|
||||
}
|
||||
|
||||
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
|
||||
username := strings.TrimSpace(record.Username)
|
||||
hasPassword := record.Password != ""
|
||||
switch {
|
||||
case username != "" && hasPassword:
|
||||
return "saved username and password"
|
||||
case username != "":
|
||||
return "saved username"
|
||||
case hasPassword:
|
||||
return "saved password"
|
||||
default:
|
||||
return "location only"
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||
items := u.selectedAttachmentItems()
|
||||
if len(items) == 0 {
|
||||
@@ -1050,7 +852,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := "Group Tools"
|
||||
size := unit.Sp(12)
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
size = unit.Sp(11)
|
||||
}
|
||||
lbl := material.Label(u.theme, size, label)
|
||||
@@ -1060,7 +862,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
})
|
||||
}
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return content(gtx)
|
||||
}
|
||||
return compactCard(gtx, content)
|
||||
@@ -1125,7 +927,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
||||
@@ -1184,7 +986,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.mode == "phone" {
|
||||
if u.usesCompactViewport() {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
|
||||
@@ -1252,13 +1054,17 @@ func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Ed
|
||||
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
func localVaultPathHelpForRuntime(goos string) string {
|
||||
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) {
|
||||
return "Choose the existing .kdbx file to open."
|
||||
}
|
||||
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
|
||||
}
|
||||
|
||||
func localVaultPathHelp() string {
|
||||
return localVaultPathHelpForRuntime(runtime.GOOS)
|
||||
}
|
||||
|
||||
func keyFileHelp() string {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return "Optional path to a KeePass-compatible key file."
|
||||
@@ -1267,7 +1073,7 @@ func keyFileHelp() string {
|
||||
}
|
||||
|
||||
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) {
|
||||
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
|
||||
}
|
||||
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/key"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
)
|
||||
|
||||
type focusID string
|
||||
@@ -0,0 +1,546 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/internal/appui/actions"
|
||||
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||
if u.usesCompactViewport() {
|
||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return u.headerActions(gtx)
|
||||
}
|
||||
if u.shouldShowDesktopWorkingHeader() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.brandMark(gtx, 196, 56)
|
||||
}),
|
||||
layout.Rigid(u.headerActions),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
if u.shouldShowLifecycleSetup() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if u.shouldShowDesktopWorkingHeader() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
|
||||
row := func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
metrics.SyncDims = u.syncButtonGroup(gtx)
|
||||
return metrics.SyncDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||
metrics.LockDims = btn.Layout(gtx)
|
||||
return metrics.LockDims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||
return metrics.MainDims
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
rowOps := op.Record(gtx.Ops)
|
||||
metrics.RowDims = row(gtx)
|
||||
rowCall := rowOps.Stop()
|
||||
|
||||
if u.usesCompactViewport() {
|
||||
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
||||
}
|
||||
|
||||
surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
||||
|
||||
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
|
||||
rowCall.Add(gtx.Ops)
|
||||
rowStack.Pop()
|
||||
|
||||
if u.usesCompactViewport() {
|
||||
if u.syncMenuOpen {
|
||||
u.phoneSyncMenuVisible = true
|
||||
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
|
||||
}
|
||||
if u.mainMenuOpen {
|
||||
u.phoneMainMenuVisible = true
|
||||
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
|
||||
}
|
||||
width := gtx.Constraints.Max.X
|
||||
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
|
||||
}
|
||||
|
||||
if u.syncMenuOpen {
|
||||
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
|
||||
}
|
||||
if u.mainMenuOpen {
|
||||
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
|
||||
}
|
||||
|
||||
width := metrics.RowDims.Size.X
|
||||
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
|
||||
}
|
||||
|
||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||
},
|
||||
}
|
||||
rowWidth := menuActionWidth(gtx, rows)
|
||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[0])
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[1])
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[2])
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[3])
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[4])
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, rowWidth, rows[5])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||
label := "Sync"
|
||||
spacing := unit.Dp(4)
|
||||
if u.usesCompactViewport() {
|
||||
spacing = unit.Dp(3)
|
||||
}
|
||||
row := func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncMenuToggle(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return row(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
|
||||
if u.syncMenuOpen {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
if u.usesCompactViewport() {
|
||||
btn.Size = unit.Dp(16)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(7))
|
||||
}
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
||||
model := u.buildSyncMenuModel()
|
||||
profiles := u.availableRemoteProfiles()
|
||||
credentials := u.availableRemoteCredentialEntries()
|
||||
if len(u.vaultRemoteProfileClicks) < len(profiles) {
|
||||
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
|
||||
}
|
||||
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
|
||||
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
|
||||
}
|
||||
actionRows := u.syncMenuActionRows(model)
|
||||
actionWidth := menuActionWidth(gtx, actionRows)
|
||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
rows := u.syncMenuRows(model, profiles, credentials, actionWidth)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget {
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
||||
},
|
||||
}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
|
||||
})
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
||||
if model.ShowSaveCurrentBinding {
|
||||
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild {
|
||||
rows := []layout.FlexChild{
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
}
|
||||
if model.ShowShare {
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
)
|
||||
}))
|
||||
}
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
|
||||
if model.ShowRemoteSyncSetupShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
|
||||
}
|
||||
if model.ShowDirectRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoteSyncSettingsShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
|
||||
}
|
||||
if model.ShowRemoveRemoteSyncShortcut() {
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
|
||||
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, click, label)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
}
|
||||
if !model.ShowSelectors {
|
||||
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
|
||||
} else {
|
||||
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
|
||||
}
|
||||
if _, ok := u.selectedVaultRemoteProfile(); ok {
|
||||
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
|
||||
rows = append(rows,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
summary := model.SavedBindingSummary
|
||||
if !summary.OK {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild {
|
||||
return []layout.FlexChild{
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
||||
for i, profile := range profiles {
|
||||
i := i
|
||||
profile := profile
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteProfileID == profile.ID
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
for i, entry := range credentials {
|
||||
i := i
|
||||
entry := entry
|
||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
|
||||
label := entry.Title
|
||||
if entry.Username != "" {
|
||||
label += " · " + entry.Username
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
||||
macro := op.Record(gtx.Ops)
|
||||
contentDims := w(measureGTX)
|
||||
_ = macro.Stop()
|
||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
||||
maxWidth := gtx.Constraints.Max.X
|
||||
if maxWidth > 0 && width > maxWidth {
|
||||
width = maxWidth
|
||||
}
|
||||
if width > 0 {
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
}
|
||||
return compactCard(gtx, w)
|
||||
}
|
||||
|
||||
func (u *ui) topRightActionOrder() []string {
|
||||
if u.isVaultLocked() {
|
||||
return nil
|
||||
}
|
||||
return []string{"Sync", "Lock", "Menu"}
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||
button := func(gtx layout.Context) layout.Dimensions {
|
||||
icon := u.menuIcon
|
||||
if icon == nil {
|
||||
icon = u.settingsIcon
|
||||
}
|
||||
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
||||
if u.mainMenuOpen {
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
} else {
|
||||
btn.Background = selectedColor
|
||||
btn.Color = accentColor
|
||||
}
|
||||
btn.Size = unit.Dp(18)
|
||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
return button(gtx)
|
||||
}
|
||||
|
||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
if !u.usesCompactViewport() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
contentInsetPx := gtx.Dp(unit.Dp(16))
|
||||
surface := appuilayout.DropdownSurface{
|
||||
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
||||
LeftInset: contentInsetPx,
|
||||
TopInset: contentInsetPx,
|
||||
}
|
||||
|
||||
if u.syncMenuVisibleOnPhone() {
|
||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
||||
}
|
||||
if u.mainMenuVisibleOnPhone() {
|
||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuDropsBelowTrigger() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *ui) syncMenuRightAlignsToTrigger() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *ui) headerMenusUseOverlayModel() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuDropsBelowTrigger() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *ui) mainMenuRightAlignsToTrigger() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||
width := 0
|
||||
for _, row := range rows {
|
||||
measureGTX := gtx
|
||||
measureGTX.Constraints.Min = image.Point{}
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := row(measureGTX)
|
||||
_ = macro.Stop()
|
||||
if dims.Size.X > width {
|
||||
width = dims.Size.X
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||
if width <= 0 {
|
||||
return child(gtx)
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return layout.E.Layout(gtx, child)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||
panel := card
|
||||
if u.usesCompactViewport() {
|
||||
panel = compactCard
|
||||
}
|
||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
rows := []layout.Widget{
|
||||
u.lifecycleBranding,
|
||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||
u.lifecycleControls,
|
||||
}
|
||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
return rows[i](gtx)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package appui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
|
||||
"git.julianfamily.org/keepassgo/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -0,0 +1,223 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(620))
|
||||
if width > gtx.Constraints.Max.X {
|
||||
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||
}
|
||||
if width < 1 {
|
||||
width = gtx.Constraints.Max.X
|
||||
}
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return card(gtx, u.syncDialogContent)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
||||
}
|
||||
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncDirectionChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !u.shouldShowSyncSourceChoices() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.syncSourceMode == syncSourceRemote {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncPasswordField(gtx)
|
||||
}),
|
||||
}
|
||||
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
|
||||
check.Color = accentColor
|
||||
return check.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
if len(matchingCredentials) > 0 {
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
for i, entry := range matchingCredentials {
|
||||
i := i
|
||||
entry := entry
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := entry.Title
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
label += " · " + strings.TrimSpace(entry.Username)
|
||||
}
|
||||
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
|
||||
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), label)
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
if supportsDesktopFilePicker(runtime.GOOS) {
|
||||
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
|
||||
}
|
||||
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
field := func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
|
||||
editor.Color = u.theme.Palette.Fg
|
||||
editor.HintColor = mutedColor
|
||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return u.outlinedFieldState(gtx, false, field)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package appui
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions"
|
||||
)
|
||||
|
||||
func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel {
|
||||
model := appuiactions.SyncMenuModel{
|
||||
HasOpenVault: u.hasOpenVault(),
|
||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
||||
RemotePassword: u.remotePassword.Text(),
|
||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||
}
|
||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
||||
return model
|
||||
}
|
||||
|
||||
func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary {
|
||||
profile, ok := u.selectedVaultRemoteProfile()
|
||||
if !ok {
|
||||
return appuiactions.SyncMenuBindingSummary{}
|
||||
}
|
||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
||||
if !ok {
|
||||
return appuiactions.SyncMenuBindingSummary{}
|
||||
}
|
||||
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 appuiactions.SyncMenuBindingSummary{
|
||||
ProfileLabel: profile.Name,
|
||||
CredentialLabel: credentialLabel,
|
||||
SyncLabel: syncLabel,
|
||||
OK: true,
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
systemclipboard "github.com/atotto/clipboard"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/vault"
|
||||
"git.julianfamily.org/keepassgo/webdav"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package vault
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
db.Credentials = credentials
|
||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||
db.Content.Meta.CustomData = customDataForModel(model)
|
||||
db.Content.Root = &gokeepasslib.RootData{}
|
||||
if config != nil && config.Header != nil {
|
||||
db.Header = cloneHeader(config.Header)
|
||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
for _, group := range db.Content.Root.Groups {
|
||||
appendGroupEntries(&model, db, group, nil)
|
||||
}
|
||||
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||
|
||||
return model, &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||
if len(model.RemoteProfiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := json.Marshal(model.RemoteProfiles)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []gokeepasslib.CustomData{{
|
||||
Key: remoteProfilesKey,
|
||||
Value: string(content),
|
||||
}}
|
||||
}
|
||||
|
||||
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
for _, item := range meta.CustomData {
|
||||
if item.Key != remoteProfilesKey {
|
||||
continue
|
||||
}
|
||||
var profiles []RemoteProfile
|
||||
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||
return nil
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||
switch {
|
||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||
),
|
||||
mustGroup("Home Assistant",
|
||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||
mustGroup("Security Office",
|
||||
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
}
|
||||
|
||||
groups := model.ChildGroups([]string{"Root"})
|
||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
||||
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
||||
entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
|
||||
entry.Tags = "automation; home"
|
||||
entry.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", entry)),
|
||||
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Fatalf("LoadKDBX failed: %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "token-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
||||
if got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
|
||||
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
Tags: []string{"automation", "home"},
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(homeAssistant) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
|
||||
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||
}
|
||||
|
||||
if homeAssistant[0].Password != "token-2" {
|
||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
||||
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RemoteProfiles: []RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
if len(loaded.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||
}
|
||||
|
||||
got := loaded.RemoteProfiles[0]
|
||||
if got.ID != "bellagio-webdav" || got.Name != "Bellagio Vault" {
|
||||
t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio Vault", got)
|
||||
}
|
||||
if got.Backend != RemoteBackendWebDAV {
|
||||
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||
}
|
||||
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||
}
|
||||
if got.Path != "files/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||
}
|
||||
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||
}
|
||||
}
|
||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
||||
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||
}
|
||||
}
|
||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
||||
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -8,6 +8,21 @@ import (
|
||||
|
||||
var ErrEntryNotFound = errors.New("entry not found")
|
||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||
|
||||
type RemoteBackend string
|
||||
|
||||
const (
|
||||
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||
)
|
||||
|
||||
type RemoteProfile struct {
|
||||
ID string
|
||||
Name string
|
||||
Backend RemoteBackend
|
||||
BaseURL string
|
||||
Path string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
|
||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||
}
|
||||
|
||||
func (m *Model) RemoveEntryByID(id string) bool {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||
for _, entry := range m.Entries {
|
||||
if entry.ID == id {
|
||||
return cloneEntry(entry), nil
|
||||
}
|
||||
}
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != profile.ID {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles[i] = profile
|
||||
return
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||
}
|
||||
|
||||
func (m *Model) RemoveRemoteProfileByID(id string) bool {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||
for _, profile := range m.RemoteProfiles {
|
||||
if profile.ID == id {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertTemplate(entry Entry) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != entry.ID {
|
||||
@@ -13,10 +13,10 @@ type SecuritySettings struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CipherAES256 = "aes256"
|
||||
CipherAES256 = "aes256"
|
||||
CipherChaCha20 = "chacha20"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
)
|
||||
|
||||
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = keepassgo-git
|
||||
pkgdesc = KeePass-compatible password manager written in Go
|
||||
pkgver = r160.5fa79bd
|
||||
pkgver = r165.1c72a50
|
||||
pkgrel = 1
|
||||
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||
arch = x86_64
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=keepassgo-git
|
||||
pkgver=r0.0000000
|
||||
pkgver=@PKGVER@
|
||||
pkgrel=1
|
||||
pkgdesc='KeePass-compatible password manager written in Go'
|
||||
arch=('x86_64' 'aarch64')
|
||||
@@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
||||
sha256sums=('SKIP')
|
||||
|
||||
_repo_dir() {
|
||||
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
||||
printf '%s\n' "${srcdir}/keepassgo"
|
||||
return
|
||||
fi
|
||||
|
||||
cd "${startdir}/../../.." || exit 1
|
||||
pwd
|
||||
printf '%s\n' "@REPO_DIR@"
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
@@ -47,16 +41,16 @@ build() {
|
||||
export GOFLAGS="-trimpath"
|
||||
local app_version
|
||||
app_version="$(git describe --tags --always --dirty)"
|
||||
go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo .
|
||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$(_repo_dir)"
|
||||
|
||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||
install -Dm644 assets/keepassgo-icon.png \
|
||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||
install -Dm644 assets/keepassgo-icon.svg \
|
||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
||||
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
|
||||
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
|
||||
"${pkgdir}/usr/share/applications/keepassgo.desktop"
|
||||
@@ -1,67 +0,0 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: debian/testing
|
||||
packages:
|
||||
- clang
|
||||
- cmake
|
||||
- curl
|
||||
- autoconf
|
||||
- libxml2-dev
|
||||
- libssl-dev
|
||||
- libz-dev
|
||||
- llvm-dev # for cctools
|
||||
- uuid-dev ## for cctools
|
||||
- libplist-utils # for gogio
|
||||
sources:
|
||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||
- https://git.sr.ht/~eliasnaur/applesdks
|
||||
- https://git.sr.ht/~eliasnaur/giouiorg
|
||||
- https://github.com/tpoechtrager/cctools-port.git
|
||||
- https://github.com/tpoechtrager/apple-libtapi.git
|
||||
- https://github.com/mackyle/xar.git
|
||||
environment:
|
||||
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
|
||||
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
|
||||
tasks:
|
||||
- install_go: |
|
||||
mkdir -p /home/build/sdk
|
||||
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||
- prepare_toolchain: |
|
||||
mkdir -p $APPLE_TOOLCHAIN_ROOT
|
||||
cd $APPLE_TOOLCHAIN_ROOT
|
||||
tar xJf /home/build/applesdks/applesdks.tar.xz
|
||||
mkdir bin tools
|
||||
cd bin
|
||||
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
|
||||
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
|
||||
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
|
||||
ln -s ../tools/appletoolchain xcrun
|
||||
ln -s /usr/bin/plistutil plutil
|
||||
cd ../tools
|
||||
ln -s appletoolchain clang-ios
|
||||
ln -s appletoolchain clang-macos
|
||||
- install_appletoolchain: |
|
||||
cd giouiorg
|
||||
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
|
||||
- build_xar: |
|
||||
cd xar/xar
|
||||
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
- build_libtapi: |
|
||||
cd apple-libtapi
|
||||
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
|
||||
./install.sh
|
||||
- build_cctools: |
|
||||
cd cctools-port/cctools
|
||||
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
|
||||
make install
|
||||
- install_gogio: |
|
||||
cd gio-cmd
|
||||
go install ./gogio
|
||||
- test_ios_gogio: |
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
go mod init example.com
|
||||
go get -d gioui.org/example/kitchen
|
||||
export PATH=/home/build/appletools/bin:$PATH
|
||||
gogio -target ios -o app.app gioui.org/example/kitchen
|
||||
@@ -1,22 +0,0 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: freebsd/13.x
|
||||
packages:
|
||||
- libX11
|
||||
- libxkbcommon
|
||||
- libXcursor
|
||||
- libXfixes
|
||||
- vulkan-headers
|
||||
- wayland
|
||||
- mesa-libs
|
||||
- xorg-vfbserver
|
||||
sources:
|
||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||
environment:
|
||||
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||
tasks:
|
||||
- install_go: |
|
||||
mkdir -p /home/build/sdk
|
||||
curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||
- test_cmd: |
|
||||
cd gio-cmd
|
||||
go test ./...
|
||||
@@ -1,91 +0,0 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: debian/bookworm
|
||||
packages:
|
||||
- curl
|
||||
- pkg-config
|
||||
- libwayland-dev
|
||||
- libx11-dev
|
||||
- libx11-xcb-dev
|
||||
- libxkbcommon-dev
|
||||
- libxkbcommon-x11-dev
|
||||
- libgles2-mesa-dev
|
||||
- libegl1-mesa-dev
|
||||
- libffi-dev
|
||||
- libvulkan-dev
|
||||
- libxcursor-dev
|
||||
- libxrandr-dev
|
||||
- libxinerama-dev
|
||||
- libxi-dev
|
||||
- libxxf86vm-dev
|
||||
- mesa-vulkan-drivers
|
||||
- wine
|
||||
- xvfb
|
||||
- xdotool
|
||||
- scrot
|
||||
- sway
|
||||
- grim
|
||||
- wine
|
||||
- unzip
|
||||
sources:
|
||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||
environment:
|
||||
PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
|
||||
ANDROID_SDK_ROOT: /home/build/android
|
||||
android_sdk_tools_zip: sdk-tools-linux-3859397.zip
|
||||
android_ndk_zip: android-ndk-r20-linux-x86_64.zip
|
||||
github_mirror: git@github.com:gioui/gio-cmd
|
||||
secrets:
|
||||
- fdc570bf-87f4-4528-8aee-4d1711b1c86f
|
||||
tasks:
|
||||
- install_go: |
|
||||
mkdir -p /home/build/sdk
|
||||
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
|
||||
- check_gofmt: |
|
||||
cd gio-cmd
|
||||
test -z "$(gofmt -s -l .)"
|
||||
- check_sign_off: |
|
||||
set +x -e
|
||||
cd gio-cmd
|
||||
for hash in $(git log -n 20 --format="%H"); do
|
||||
message=$(git log -1 --format=%B $hash)
|
||||
if [[ ! "$message" =~ "Signed-off-by: " ]]; then
|
||||
echo "Missing 'Signed-off-by' in commit $hash"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- mirror: |
|
||||
# mirror to github
|
||||
ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
|
||||
- install_chrome: |
|
||||
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||
sudo apt-get -qq update
|
||||
sudo apt-get -qq install -y google-chrome-stable
|
||||
- test: |
|
||||
cd gio-cmd
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
- install_jdk8: |
|
||||
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
|
||||
sudo apt-get -qq install -y -f ./jdk.deb
|
||||
- install_android: |
|
||||
mkdir android
|
||||
cd android
|
||||
curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
|
||||
unzip -q sdk-tools.zip
|
||||
rm sdk-tools.zip
|
||||
curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
|
||||
unzip -q ndk.zip
|
||||
rm ndk.zip
|
||||
mv android-ndk-* ndk-bundle
|
||||
yes|sdkmanager --licenses
|
||||
sdkmanager "platforms;android-31" "build-tools;32.0.0"
|
||||
- install_gogio: |
|
||||
cd gio-cmd
|
||||
go install ./gogio
|
||||
- test_android_gogio: |
|
||||
mkdir tmp
|
||||
cd tmp
|
||||
go mod init example.com
|
||||
go get -d gioui.org/example/kitchen
|
||||
gogio -target android gioui.org/example/kitchen
|
||||
@@ -1,18 +0,0 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: openbsd/latest
|
||||
packages:
|
||||
- libxkbcommon
|
||||
- go
|
||||
sources:
|
||||
- https://git.sr.ht/~eliasnaur/gio-cmd
|
||||
environment:
|
||||
PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
|
||||
tasks:
|
||||
- install_go: |
|
||||
mkdir -p /home/build/sdk
|
||||
curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
|
||||
cd /home/build/sdk/go/src
|
||||
./make.bash
|
||||
- test_cmd: |
|
||||
cd gio-cmd
|
||||
go test ./...
|
||||
@@ -1,63 +0,0 @@
|
||||
This project is provided under the terms of the UNLICENSE or
|
||||
the MIT license denoted by the following SPDX identifier:
|
||||
|
||||
SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
You may use the project under the terms of either license.
|
||||
|
||||
Both licenses are reproduced below.
|
||||
|
||||
----
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019 The Gio authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
The UNLICENSE
|
||||
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
---
|
||||
@@ -1,21 +0,0 @@
|
||||
# Gio Tools
|
||||
|
||||
Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
|
||||
|
||||
[](https://builds.sr.ht/~eliasnaur/gio-cmd)
|
||||
|
||||
## Issues
|
||||
|
||||
File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
|
||||
to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
|
||||
mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).
|
||||
|
||||
## Contributing
|
||||
|
||||
Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
|
||||
[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
|
||||
account is required and you can post without being subscribed.
|
||||
|
||||
See the [contribution guide](https://gioui.org/doc/contribute) for more details.
|
||||
|
||||
An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.
|
||||
@@ -1,28 +0,0 @@
|
||||
module gioui.org/cmd
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
github.com/akavel/rsrc v0.10.1
|
||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
|
||||
github.com/chromedp/chromedp v0.5.2
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/sync v0.7.0
|
||||
golang.org/x/text v0.16.0
|
||||
golang.org/x/tools v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
|
||||
github.com/gobwas/pool v0.2.0 // indirect
|
||||
github.com/gobwas/ws v1.0.2 // indirect
|
||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
|
||||
github.com/mailru/easyjson v0.7.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/mod v0.19.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
|
||||
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
|
||||
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
|
||||
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
|
||||
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
|
||||
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
|
||||
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
|
||||
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
|
||||
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
@@ -1,143 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type AndroidTestDriver struct {
|
||||
driverBase
|
||||
|
||||
sdkDir string
|
||||
adbPath string
|
||||
}
|
||||
|
||||
var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)
|
||||
|
||||
func (d *AndroidTestDriver) Start(path string) {
|
||||
d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
|
||||
if d.sdkDir == "" {
|
||||
d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
|
||||
}
|
||||
d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
|
||||
if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
|
||||
d.Skipf("adb not found")
|
||||
}
|
||||
|
||||
devOut := bytes.TrimSpace(d.adb("devices"))
|
||||
devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
|
||||
switch len(devices) {
|
||||
case 0:
|
||||
d.Skipf("no Android devices attached via adb; skipping")
|
||||
case 1:
|
||||
default:
|
||||
d.Skipf("multiple Android devices attached via adb; skipping")
|
||||
}
|
||||
|
||||
// If the device is attached but asleep, it's probably just charging.
|
||||
// Don't use it; the screen needs to be on and unlocked for the test to
|
||||
// work.
|
||||
if !bytes.Contains(
|
||||
d.adb("shell", "dumpsys", "power"),
|
||||
[]byte(" mWakefulness=Awake"),
|
||||
) {
|
||||
d.Skipf("Android device isn't awake; skipping")
|
||||
}
|
||||
|
||||
// First, build the app.
|
||||
apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
|
||||
d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)
|
||||
|
||||
// Make sure the app isn't installed already, and try to uninstall it
|
||||
// when we finish. Previous failed test runs might have left the app.
|
||||
d.tryUninstall()
|
||||
d.adb("install", apk)
|
||||
d.Cleanup(d.tryUninstall)
|
||||
|
||||
// Force our e2e app to be fullscreen, so that the android system bar at
|
||||
// the top doesn't mess with our screenshots.
|
||||
// TODO(mvdan): is there a way to do this via gio, so that we don't need
|
||||
// to set up a global Android setting via the shell?
|
||||
d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)
|
||||
|
||||
// Make sure the app isn't already running.
|
||||
d.adb("shell", "pm", "clear", appid)
|
||||
|
||||
// Start listening for log messages.
|
||||
{
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(ctx, d.adbPath,
|
||||
"logcat",
|
||||
"-s", // suppress other logs
|
||||
"-T1", // don't show previous log messages
|
||||
appid+":*", // show all logs from our gio app ID
|
||||
)
|
||||
output, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
d.output = output
|
||||
if err := cmd.Start(); err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
d.Cleanup(cancel)
|
||||
}
|
||||
|
||||
// Start the app.
|
||||
d.adb("shell", "monkey", "-p", appid, "1")
|
||||
|
||||
// Wait for the gio app to render.
|
||||
d.waitForFrame()
|
||||
}
|
||||
|
||||
func (d *AndroidTestDriver) Screenshot() image.Image {
|
||||
out := d.adb("shell", "screencap", "-p")
|
||||
img, err := png.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func (d *AndroidTestDriver) tryUninstall() {
|
||||
cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if bytes.Contains(out, []byte("Unknown package")) {
|
||||
// The package is not installed. Don't log anything.
|
||||
return
|
||||
}
|
||||
d.Logf("could not uninstall: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
|
||||
strs := []string{}
|
||||
for _, arg := range args {
|
||||
strs = append(strs, fmt.Sprint(arg))
|
||||
}
|
||||
cmd := exec.Command(d.adbPath, strs...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
d.Errorf("%s", out)
|
||||
d.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *AndroidTestDriver) Click(x, y int) {
|
||||
d.adb("shell", "input", "tap", x, y)
|
||||
|
||||
// Wait for the gio app to render after this click.
|
||||
d.waitForFrame()
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type buildInfo struct {
|
||||
appID string
|
||||
archs []string
|
||||
ldflags string
|
||||
minsdk int
|
||||
targetsdk int
|
||||
name string
|
||||
pkgDir string
|
||||
pkgPath string
|
||||
iconPath string
|
||||
tags string
|
||||
target string
|
||||
version Semver
|
||||
key string
|
||||
password string
|
||||
notaryAppleID string
|
||||
notaryPassword string
|
||||
notaryTeamID string
|
||||
}
|
||||
|
||||
type Semver struct {
|
||||
Major, Minor, Patch int
|
||||
VersionCode uint32
|
||||
}
|
||||
|
||||
func newBuildInfo(pkgPath string) (*buildInfo, error) {
|
||||
pkgMetadata, err := getPkgMetadata(pkgPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appID := getAppID(pkgMetadata)
|
||||
appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
|
||||
if *iconPath != "" {
|
||||
appIcon = *iconPath
|
||||
}
|
||||
appName := getPkgName(pkgMetadata)
|
||||
if *name != "" {
|
||||
appName = *name
|
||||
}
|
||||
ver, err := parseSemver(*version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bi := &buildInfo{
|
||||
appID: appID,
|
||||
archs: getArchs(),
|
||||
ldflags: getLdFlags(appID),
|
||||
minsdk: *minsdk,
|
||||
targetsdk: *targetsdk,
|
||||
name: appName,
|
||||
pkgDir: pkgMetadata.Dir,
|
||||
pkgPath: pkgPath,
|
||||
iconPath: appIcon,
|
||||
tags: *extraTags,
|
||||
target: *target,
|
||||
version: ver,
|
||||
key: *signKey,
|
||||
password: *signPass,
|
||||
notaryAppleID: *notaryID,
|
||||
notaryPassword: *notaryPass,
|
||||
notaryTeamID: *notaryTeamID,
|
||||
}
|
||||
return bi, nil
|
||||
}
|
||||
|
||||
// UppercaseName returns a string with its first rune in uppercase.
|
||||
func UppercaseName(name string) string {
|
||||
ch, w := utf8.DecodeRuneInString(name)
|
||||
return string(unicode.ToUpper(ch)) + name[w:]
|
||||
}
|
||||
|
||||
func (s Semver) String() string {
|
||||
return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode)
|
||||
}
|
||||
|
||||
func parseSemver(v string) (Semver, error) {
|
||||
var sv Semver
|
||||
_, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode)
|
||||
if err != nil {
|
||||
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||
}
|
||||
if sv.String() != v {
|
||||
return Semver{}, fmt.Errorf("invalid semver: %q", v)
|
||||
}
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func getArchs() []string {
|
||||
if *archNames != "" {
|
||||
return strings.Split(*archNames, ",")
|
||||
}
|
||||
switch *target {
|
||||
case "js":
|
||||
return []string{"wasm"}
|
||||
case "ios", "tvos":
|
||||
// Only 64-bit support.
|
||||
return []string{"arm64", "amd64"}
|
||||
case "android":
|
||||
return []string{"arm", "arm64", "386", "amd64"}
|
||||
case "windows":
|
||||
goarch := os.Getenv("GOARCH")
|
||||
if goarch == "" {
|
||||
goarch = runtime.GOARCH
|
||||
}
|
||||
return []string{goarch}
|
||||
case "macos":
|
||||
return []string{"arm64", "amd64"}
|
||||
default:
|
||||
// TODO: Add flag tests.
|
||||
panic("The target value has already been validated, this will never execute.")
|
||||
}
|
||||
}
|
||||
|
||||
func getLdFlags(appID string) string {
|
||||
var ldflags []string
|
||||
if extra := *extraLdflags; extra != "" {
|
||||
ldflags = append(ldflags, strings.Split(extra, " ")...)
|
||||
}
|
||||
// Pass appID along, to be used for logging on platforms like Android.
|
||||
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID))
|
||||
// Support earlier Gio versions that had a separate app id recorded.
|
||||
// TODO: delete this in the future.
|
||||
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
|
||||
// Pass along all remaining arguments to the app.
|
||||
if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
|
||||
ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
|
||||
}
|
||||
if m := *linkMode; m != "" {
|
||||
ldflags = append(ldflags, "-linkmode="+m)
|
||||
}
|
||||
return strings.Join(ldflags, " ")
|
||||
}
|
||||
|
||||
type packageMetadata struct {
|
||||
PkgPath string
|
||||
Dir string
|
||||
}
|
||||
|
||||
func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
|
||||
pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &packageMetadata{
|
||||
PkgPath: pkgImportPath,
|
||||
Dir: pkgDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getAppID(pkgMetadata *packageMetadata) string {
|
||||
if *appID != "" {
|
||||
return *appID
|
||||
}
|
||||
elems := strings.Split(pkgMetadata.PkgPath, "/")
|
||||
domain := strings.Split(elems[0], ".")
|
||||
name := ""
|
||||
if len(elems) > 1 {
|
||||
name = "." + elems[len(elems)-1]
|
||||
}
|
||||
if len(elems) < 2 && len(domain) < 2 {
|
||||
name = "." + domain[0]
|
||||
domain[0] = "localhost"
|
||||
} else {
|
||||
for i := 0; i < len(domain)/2; i++ {
|
||||
opp := len(domain) - 1 - i
|
||||
domain[i], domain[opp] = domain[opp], domain[i]
|
||||
}
|
||||
}
|
||||
|
||||
pkgDomain := strings.Join(domain, ".")
|
||||
appid := []rune(pkgDomain + name)
|
||||
|
||||
// a Java-language-style package name may contain upper- and lower-case
|
||||
// letters and underscores with individual parts separated by '.'.
|
||||
// https://developer.android.com/guide/topics/manifest/manifest-element
|
||||
for i, c := range appid {
|
||||
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
|
||||
c == '_' || c == '.') {
|
||||
appid[i] = '_'
|
||||
}
|
||||
}
|
||||
return string(appid)
|
||||
}
|
||||
|
||||
func getPkgName(pkgMetadata *packageMetadata) string {
|
||||
return path.Base(pkgMetadata.PkgPath)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
type expval struct {
|
||||
in, out string
|
||||
}
|
||||
|
||||
func TestAppID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []expval{
|
||||
{"example", "localhost.example"},
|
||||
{"example.com", "com.example"},
|
||||
{"www.example.com", "com.example.www"},
|
||||
{"examplecom/app", "examplecom.app"},
|
||||
{"example.com/app", "com.example.app"},
|
||||
{"www.example.com/app", "com.example.www.app"},
|
||||
{"www.en.example.com/app", "com.example.en.www.app"},
|
||||
{"example.com/dir/app", "com.example.app"},
|
||||
{"example.com/dir.ext/app", "com.example.app"},
|
||||
{"example.com/dir/app.ext", "com.example.app.ext"},
|
||||
{"example-com.net/dir/app", "net.example_com.app"},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
got := getAppID(&packageMetadata{PkgPath: test.in})
|
||||
if exp := test.out; got != exp {
|
||||
t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
/*
|
||||
The gogio tool builds and packages Gio programs for Android, iOS/tvOS
|
||||
and WebAssembly.
|
||||
|
||||
Run gogio with no arguments for instructions, or see the examples at
|
||||
https://gioui.org.
|
||||
*/
|
||||
package main
|
||||
@@ -1,337 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var raceEnabled = false
|
||||
|
||||
var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")
|
||||
|
||||
const appid = "localhost.gogio.endtoend"
|
||||
|
||||
// TestDriver is implemented by each of the platforms we can run end-to-end
|
||||
// tests on. None of its methods return any errors, as the errors are directly
|
||||
// reported to testing.T via methods like Fatal.
|
||||
type TestDriver interface {
|
||||
initBase(t *testing.T, width, height int)
|
||||
|
||||
// Start opens the Gio app found at path. The driver should attempt to
|
||||
// run the app with the base driver's width and height, and the
|
||||
// platform's background should be white.
|
||||
//
|
||||
// When the function returns, the gio app must be ready to use on the
|
||||
// platform, with its initial frame fully drawn.
|
||||
Start(path string)
|
||||
|
||||
// Screenshot takes a screenshot of the Gio app on the platform.
|
||||
Screenshot() image.Image
|
||||
|
||||
// Click performs a pointer click at the specified coordinates,
|
||||
// including both press and release. It returns when the next frame is
|
||||
// fully drawn.
|
||||
Click(x, y int)
|
||||
}
|
||||
|
||||
type driverBase struct {
|
||||
*testing.T
|
||||
|
||||
width, height int
|
||||
|
||||
output io.Reader
|
||||
frameNotifs chan bool
|
||||
}
|
||||
|
||||
func (d *driverBase) initBase(t *testing.T, width, height int) {
|
||||
d.T = t
|
||||
d.width, d.height = width, height
|
||||
}
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skipf("end-to-end tests tend to be slow")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal"
|
||||
testdataWithRelativePkgPath = "internal/normal/testdata.go"
|
||||
customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go"
|
||||
)
|
||||
// Keep this list local, to not reuse TestDriver objects.
|
||||
subtests := []struct {
|
||||
name string
|
||||
driver TestDriver
|
||||
pkgPath string
|
||||
skipGeese string
|
||||
}{
|
||||
{"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""},
|
||||
{"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""},
|
||||
{"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"},
|
||||
// Doesn't work on the builders.
|
||||
//{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
|
||||
{"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||
{"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||
{"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""},
|
||||
}
|
||||
|
||||
for _, subtest := range subtests {
|
||||
t.Run(subtest.name, func(t *testing.T) {
|
||||
subtest := subtest // copy the changing loop variable
|
||||
if strings.Contains(subtest.skipGeese, runtime.GOOS) {
|
||||
t.Skipf("not supported on %s", runtime.GOOS)
|
||||
}
|
||||
t.Parallel()
|
||||
runEndToEndTest(t, subtest.driver, subtest.pkgPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
|
||||
size := image.Point{X: 800, Y: 600}
|
||||
driver.initBase(t, size.X, size.Y)
|
||||
|
||||
t.Log("starting driver and gio app")
|
||||
driver.Start(pkgPath)
|
||||
|
||||
beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
|
||||
white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
|
||||
black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
|
||||
gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
|
||||
red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||
|
||||
// These are the four colors at the beginning.
|
||||
t.Log("taking initial screenshot")
|
||||
withRetries(t, 4*time.Second, func() error {
|
||||
img := driver.Screenshot()
|
||||
size = img.Bounds().Size() // override the default size
|
||||
return checkImageCorners(img, beef, white, black, gray)
|
||||
})
|
||||
|
||||
// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
|
||||
// almost works to automate clicks, but the button presses end up in the
|
||||
// wrong coordinates.
|
||||
if _, ok := driver.(*WaylandTestDriver); ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Click the first and last sections to turn them red.
|
||||
t.Log("clicking twice and taking another screenshot")
|
||||
driver.Click(1*(size.X/4), 1*(size.Y/4))
|
||||
driver.Click(3*(size.X/4), 3*(size.Y/4))
|
||||
withRetries(t, 4*time.Second, func() error {
|
||||
img := driver.Screenshot()
|
||||
return checkImageCorners(img, red, white, black, red)
|
||||
})
|
||||
}
|
||||
|
||||
// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
|
||||
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
|
||||
// such, timeout should generally be in the order of seconds.
|
||||
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
|
||||
t.Helper()
|
||||
|
||||
timeoutTimer := time.NewTimer(timeout)
|
||||
defer timeoutTimer.Stop()
|
||||
backoff := 100 * time.Millisecond
|
||||
|
||||
tries := 0
|
||||
var lastErr error
|
||||
for {
|
||||
if lastErr = fn(); lastErr == nil {
|
||||
return
|
||||
}
|
||||
tries++
|
||||
t.Logf("retrying after %s", backoff)
|
||||
|
||||
// Use a timer instead of a sleep, so that the timeout can stop
|
||||
// the backoff early. Don't reuse this timer, since we're not in
|
||||
// a hot loop, and we don't want tricky code.
|
||||
backoffTimer := time.NewTimer(backoff)
|
||||
defer backoffTimer.Stop()
|
||||
|
||||
select {
|
||||
case <-timeoutTimer.C:
|
||||
t.Errorf("last error: %v", lastErr)
|
||||
t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
|
||||
case <-backoffTimer.C:
|
||||
}
|
||||
|
||||
// Keep doubling it until a maximum. With the start at 100ms,
|
||||
// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
|
||||
backoff *= 2
|
||||
if max := 2 * time.Second; backoff > max {
|
||||
backoff = max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type colorMismatch struct {
|
||||
x, y int
|
||||
wantRGB, gotRGB [3]uint32
|
||||
}
|
||||
|
||||
func (m colorMismatch) String() string {
|
||||
return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
|
||||
m.x, m.y,
|
||||
m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
|
||||
m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
|
||||
)
|
||||
}
|
||||
|
||||
func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
|
||||
// The colors are split in four rectangular sections. Check the corners
|
||||
// of each of the sections. We check the corners left to right, top to
|
||||
// bottom, like when reading left-to-right text.
|
||||
|
||||
size := img.Bounds().Size()
|
||||
var mismatches []colorMismatch
|
||||
|
||||
checkColor := func(x, y int, want color.Color) {
|
||||
r, g, b, _ := want.RGBA()
|
||||
got := img.At(x, y)
|
||||
r_, g_, b_, _ := got.RGBA()
|
||||
if r_ != r || g_ != g || b_ != b {
|
||||
mismatches = append(mismatches, colorMismatch{
|
||||
x: x,
|
||||
y: y,
|
||||
wantRGB: [3]uint32{r, g, b},
|
||||
gotRGB: [3]uint32{r_, g_, b_},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
minX, minY := 5, 5
|
||||
maxX, maxY := (size.X/2)-5, (size.Y/2)-5
|
||||
checkColor(minX, minY, topLeft)
|
||||
checkColor(maxX, minY, topLeft)
|
||||
checkColor(minX, maxY, topLeft)
|
||||
checkColor(maxX, maxY, topLeft)
|
||||
}
|
||||
{
|
||||
minX, minY := (size.X/2)+5, 5
|
||||
maxX, maxY := size.X-5, (size.Y/2)-5
|
||||
checkColor(minX, minY, topRight)
|
||||
checkColor(maxX, minY, topRight)
|
||||
checkColor(minX, maxY, topRight)
|
||||
checkColor(maxX, maxY, topRight)
|
||||
}
|
||||
{
|
||||
minX, minY := 5, (size.Y/2)+5
|
||||
maxX, maxY := (size.X/2)-5, size.Y-5
|
||||
checkColor(minX, minY, botLeft)
|
||||
checkColor(maxX, minY, botLeft)
|
||||
checkColor(minX, maxY, botLeft)
|
||||
checkColor(maxX, maxY, botLeft)
|
||||
}
|
||||
{
|
||||
minX, minY := (size.X/2)+5, (size.Y/2)+5
|
||||
maxX, maxY := size.X-5, size.Y-5
|
||||
checkColor(minX, minY, botRight)
|
||||
checkColor(maxX, minY, botRight)
|
||||
checkColor(minX, maxY, botRight)
|
||||
checkColor(maxX, maxY, botRight)
|
||||
}
|
||||
if n := len(mismatches); n > 0 {
|
||||
b := new(strings.Builder)
|
||||
fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
|
||||
for _, m := range mismatches {
|
||||
fmt.Fprintf(b, "%s\n", m)
|
||||
}
|
||||
return errors.New(b.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *driverBase) waitForFrame() {
|
||||
d.Helper()
|
||||
|
||||
if d.frameNotifs == nil {
|
||||
// Start the goroutine that reads output lines and notifies of
|
||||
// new frames via frameNotifs. The test doesn't wait for this
|
||||
// goroutine to finish; it will naturally end when the output
|
||||
// reader reaches an error like EOF.
|
||||
d.frameNotifs = make(chan bool, 1)
|
||||
if d.output == nil {
|
||||
d.Fatal("need an output reader to be notified of frames")
|
||||
}
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(d.output)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
d.Log(line)
|
||||
if strings.Contains(line, "gio frame ready") {
|
||||
d.frameNotifs <- true
|
||||
}
|
||||
}
|
||||
// Since we're only interested in the output while the
|
||||
// app runs, and we don't know when it finishes here,
|
||||
// ignore "already closed" pipe errors.
|
||||
if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||
d.Errorf("reading app output: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Unfortunately, there isn't a way to select on a test failing, since
|
||||
// testing.T doesn't have anything like a context or a "done" channel.
|
||||
//
|
||||
// We can't let selects block forever, since the default -test.timeout
|
||||
// is ten minutes - far too long for tests that take seconds.
|
||||
//
|
||||
// For now, a static short timeout is better than nothing. 5s is plenty
|
||||
// for our simple test app to render on any device.
|
||||
select {
|
||||
case <-d.frameNotifs:
|
||||
case <-time.After(5 * time.Second):
|
||||
d.Fatalf("timed out waiting for a frame to be ready")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *driverBase) needPrograms(names ...string) {
|
||||
d.Helper()
|
||||
for _, name := range names {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
d.Skipf("%s needed to run", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *driverBase) tempDir(name string) string {
|
||||
d.Helper()
|
||||
dir, err := os.MkdirTemp("", name)
|
||||
if err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
d.Cleanup(func() { os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func (d *driverBase) gogio(args ...string) {
|
||||
d.Helper()
|
||||
prog, err := os.Executable()
|
||||
if err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(prog, args...)
|
||||
cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
d.Fatalf("gogio error: %s:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package main
|
||||
|
||||
const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
|
||||
|
||||
Usage:
|
||||
|
||||
gogio -target <target> [flags] <package> [run arguments]
|
||||
|
||||
The gogio tool builds and packages Gio programs for platforms where additional
|
||||
metadata or support files are required.
|
||||
|
||||
The package argument specifies an import path or a single Go source file to
|
||||
package. Any run arguments are appended to os.Args at runtime.
|
||||
|
||||
Compiled Java class files from jar files in the package directory are
|
||||
included in Android builds.
|
||||
|
||||
The mandatory -target flag selects the target platform: ios or android for the
|
||||
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
|
||||
MacOS and windows for Windows.
|
||||
|
||||
The -arch flag specifies a comma separated list of GOARCHs to include. The
|
||||
default is all supported architectures.
|
||||
|
||||
The -o flag specifies an output file or directory, depending on the target.
|
||||
|
||||
The -buildmode flag selects the build mode. Two build modes are available, exe
|
||||
and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
|
||||
for Android or a directory with the WebAssembly module and support files for
|
||||
a browser.
|
||||
|
||||
The -ldflags and -tags flags pass extra linker flags and tags to the go tool.
|
||||
|
||||
As a special case for iOS or tvOS, specifying a path that ends with ".app"
|
||||
will output an app directory suitable for a simulator.
|
||||
|
||||
The other buildmode is archive, which will output an .aar library for Android
|
||||
or a .framework for iOS and tvOS.
|
||||
|
||||
The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
|
||||
If left unspecified, the appicon.png file from the main package is used
|
||||
(if it exists).
|
||||
|
||||
The -appid flag specifies the package name for Android or the bundle id for
|
||||
iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
|
||||
tool can use it.
|
||||
|
||||
The -version flag specifies the integer version code for Android and the last
|
||||
component of the 1.0.X version for iOS and tvOS.
|
||||
|
||||
For Android builds the -minsdk flag specify the minimum SDK level. For example,
|
||||
use -minsdk 22 to target Android 5.1 (Lollipop) and later.
|
||||
|
||||
For Windows builds the -minsdk flag specify the minimum OS version. For example,
|
||||
use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.
|
||||
|
||||
For iOS builds the -minsdk flag specify the minimum iOS version. For example,
|
||||
use -mindk 15 to target iOS 15.0 and later.
|
||||
|
||||
For Android builds the -targetsdk flag specify the target SDK level. For example,
|
||||
use -targetsdk 33 to target Android 13 (Tiramisu) and later.
|
||||
|
||||
The -work flag prints the path to the working directory and suppress
|
||||
its deletion.
|
||||
|
||||
The -x flag will print all the external commands executed by the gogio tool.
|
||||
|
||||
The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
|
||||
or specifies the name of key on Keychain to sign MacOS app.
|
||||
|
||||
The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
|
||||
|
||||
The -notaryid flag specifies the Apple ID to use for notarization of MacOS app.
|
||||
|
||||
The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not
|
||||
provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397
|
||||
for details. If not provided, the password will be prompted.
|
||||
|
||||
The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if
|
||||
-notaryid is not provided.
|
||||
`
|
||||
@@ -1,371 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
// This program demonstrates the use of a custom OpenGL ES context with
|
||||
// app.Window.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/gpu"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
/*
|
||||
#cgo linux pkg-config: egl wayland-egl
|
||||
#cgo freebsd openbsd CFLAGS: -I/usr/local/include
|
||||
#cgo openbsd CFLAGS: -I/usr/X11R6/include
|
||||
#cgo freebsd LDFLAGS: -L/usr/local/lib
|
||||
#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
|
||||
#cgo freebsd openbsd LDFLAGS: -lwayland-egl
|
||||
#cgo CFLAGS: -DEGL_NO_X11
|
||||
#cgo LDFLAGS: -lEGL -lGLESv2
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <wayland-client.h>
|
||||
#include <wayland-egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#define EGL_EGLEXT_PROTOTYPES
|
||||
#include <EGL/eglext.h>
|
||||
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func getDisplay(ve app.ViewEvent) C.EGLDisplay {
|
||||
switch ve := ve.(type) {
|
||||
case app.X11ViewEvent:
|
||||
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||
case app.WaylandViewEvent:
|
||||
return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
|
||||
}
|
||||
panic("no display available")
|
||||
}
|
||||
|
||||
func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
|
||||
switch e := e.(type) {
|
||||
case app.X11ViewEvent:
|
||||
return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
|
||||
case app.WaylandViewEvent:
|
||||
eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
|
||||
return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
|
||||
C.wl_egl_window_destroy(eglWin)
|
||||
}
|
||||
}
|
||||
panic("no native view available")
|
||||
}
|
||||
|
||||
type (
|
||||
C = layout.Context
|
||||
D = layout.Dimensions
|
||||
)
|
||||
|
||||
type notifyFrame int
|
||||
|
||||
const (
|
||||
notifyNone notifyFrame = iota
|
||||
notifyInvalidate
|
||||
notifyPrint
|
||||
)
|
||||
|
||||
// notify keeps track of whether we want to print to stdout to notify the user
|
||||
// when a frame is ready. Initially we want to notify about the first frame.
|
||||
var notify = notifyInvalidate
|
||||
|
||||
type eglContext struct {
|
||||
disp C.EGLDisplay
|
||||
ctx C.EGLContext
|
||||
surf C.EGLSurface
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
// Set CustomRenderer so we can provide our own rendering context.
|
||||
w := new(app.Window)
|
||||
w.Option(app.CustomRenderer(true))
|
||||
if err := loop(w); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func loop(w *app.Window) error {
|
||||
var ops op.Ops
|
||||
var (
|
||||
ctx *eglContext
|
||||
gioCtx gpu.GPU
|
||||
ve app.ViewEvent
|
||||
init bool
|
||||
size image.Point
|
||||
)
|
||||
|
||||
recreateContext := func() {
|
||||
w.Run(func() {
|
||||
if gioCtx != nil {
|
||||
gioCtx.Release()
|
||||
gioCtx = nil
|
||||
}
|
||||
if ctx != nil {
|
||||
C.eglMakeCurrent(ctx.disp, nil, nil, nil)
|
||||
ctx.Release()
|
||||
ctx = nil
|
||||
}
|
||||
c, err := createContext(ve, size)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx = c
|
||||
})
|
||||
if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
|
||||
err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
|
||||
log.Fatal(err)
|
||||
}
|
||||
glGetString := func(e C.GLenum) string {
|
||||
return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
|
||||
}
|
||||
fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
|
||||
var err error
|
||||
gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
topLeft := quarterWidget{
|
||||
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||
}
|
||||
topRight := quarterWidget{
|
||||
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||
}
|
||||
botLeft := quarterWidget{
|
||||
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||
}
|
||||
botRight := quarterWidget{
|
||||
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||
}
|
||||
|
||||
// eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
|
||||
runtime.LockOSThread()
|
||||
for {
|
||||
switch e := w.Event().(type) {
|
||||
case app.ViewEvent:
|
||||
ve = e
|
||||
init = true
|
||||
if size != (image.Point{}) {
|
||||
recreateContext()
|
||||
}
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
if init && size != e.Size {
|
||||
size = e.Size
|
||||
recreateContext()
|
||||
}
|
||||
if gioCtx == nil || !init {
|
||||
break
|
||||
}
|
||||
// Build ops.
|
||||
gtx := app.NewContext(&ops, e)
|
||||
|
||||
// Clear background to white, even on embedded platforms such as webassembly.
|
||||
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
// r1c1
|
||||
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||
// r1c2
|
||||
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
// r2c1
|
||||
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||
// r2c2
|
||||
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
gtx.Execute(op.InvalidateCmd{})
|
||||
log.Println("frame")
|
||||
|
||||
// Trigger window resize detection in ANGLE.
|
||||
C.eglWaitClient()
|
||||
// Draw custom OpenGL content.
|
||||
drawGL()
|
||||
|
||||
// Render drawing ops.
|
||||
if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
|
||||
log.Fatal(fmt.Errorf("render failed: %v", err))
|
||||
}
|
||||
|
||||
// Process non-drawing ops.
|
||||
e.Frame(gtx.Ops)
|
||||
switch notify {
|
||||
case notifyInvalidate:
|
||||
notify = notifyPrint
|
||||
w.Invalidate()
|
||||
case notifyPrint:
|
||||
notify = notifyNone
|
||||
fmt.Println("gio frame ready")
|
||||
}
|
||||
|
||||
if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
|
||||
log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drawGL() {
|
||||
C.glClearColor(0, 0, 0, 1)
|
||||
C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
|
||||
}
|
||||
|
||||
func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
|
||||
view, cleanup := nativeViewFor(ve, size)
|
||||
var nilv C.EGLNativeWindowType
|
||||
if view == nilv {
|
||||
return nil, fmt.Errorf("failed creating native view")
|
||||
}
|
||||
disp := getDisplay(ve)
|
||||
if disp == 0 {
|
||||
return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
|
||||
}
|
||||
var major, minor C.EGLint
|
||||
if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
|
||||
return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
|
||||
}
|
||||
exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
|
||||
srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
|
||||
attribs := []C.EGLint{
|
||||
C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
|
||||
C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
|
||||
C.EGL_BLUE_SIZE, 8,
|
||||
C.EGL_GREEN_SIZE, 8,
|
||||
C.EGL_RED_SIZE, 8,
|
||||
C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
|
||||
}
|
||||
if srgb {
|
||||
// Some drivers need alpha for sRGB framebuffers to work.
|
||||
attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
|
||||
}
|
||||
attribs = append(attribs, C.EGL_NONE)
|
||||
var (
|
||||
cfg C.EGLConfig
|
||||
numCfgs C.EGLint
|
||||
)
|
||||
if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
|
||||
return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
|
||||
}
|
||||
if numCfgs == 0 {
|
||||
supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
|
||||
if !supportsNoCfg {
|
||||
return nil, errors.New("eglChooseConfig returned no configs")
|
||||
}
|
||||
}
|
||||
ctxAttribs := []C.EGLint{
|
||||
C.EGL_CONTEXT_CLIENT_VERSION, 3,
|
||||
C.EGL_NONE,
|
||||
}
|
||||
ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
|
||||
}
|
||||
var surfAttribs []C.EGLint
|
||||
if srgb {
|
||||
surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
|
||||
}
|
||||
surfAttribs = append(surfAttribs, C.EGL_NONE)
|
||||
surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
|
||||
if surf == nil {
|
||||
return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
|
||||
}
|
||||
return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
|
||||
}
|
||||
|
||||
func (c *eglContext) Release() {
|
||||
if c.ctx != nil {
|
||||
C.eglDestroyContext(c.disp, c.ctx)
|
||||
}
|
||||
if c.surf != nil {
|
||||
C.eglDestroySurface(c.disp, c.surf)
|
||||
}
|
||||
if c.cleanup != nil {
|
||||
c.cleanup()
|
||||
}
|
||||
*c = eglContext{}
|
||||
}
|
||||
|
||||
func hasExtension(exts []string, ext string) bool {
|
||||
for _, e := range exts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||
// turns red, going back to its normal color when clicked again.
|
||||
type quarterWidget struct {
|
||||
color color.NRGBA
|
||||
|
||||
clicked bool
|
||||
}
|
||||
|
||||
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||
|
||||
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||
var color color.NRGBA
|
||||
if w.clicked {
|
||||
color = red
|
||||
} else {
|
||||
color = w.color
|
||||
}
|
||||
|
||||
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||
|
||||
defer clip.Rect(image.Rectangle{
|
||||
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||
}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, w)
|
||||
for {
|
||||
e, ok := gtx.Event(pointer.Filter{
|
||||
Target: w,
|
||||
Kinds: pointer.Press,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||
w.clicked = !w.clicked
|
||||
// notify when we're done updating the frame.
|
||||
notify = notifyInvalidate
|
||||
}
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
// A simple app used for gogio's end-to-end tests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
w := new(app.Window)
|
||||
if err := loop(w); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
type notifyFrame int
|
||||
|
||||
const (
|
||||
notifyNone notifyFrame = iota
|
||||
notifyInvalidate
|
||||
notifyPrint
|
||||
)
|
||||
|
||||
// notify keeps track of whether we want to print to stdout to notify the user
|
||||
// when a frame is ready. Initially we want to notify about the first frame.
|
||||
var notify = notifyInvalidate
|
||||
|
||||
type (
|
||||
C = layout.Context
|
||||
D = layout.Dimensions
|
||||
)
|
||||
|
||||
func loop(w *app.Window) error {
|
||||
topLeft := quarterWidget{
|
||||
color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
|
||||
}
|
||||
topRight := quarterWidget{
|
||||
color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
|
||||
}
|
||||
botLeft := quarterWidget{
|
||||
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
|
||||
}
|
||||
botRight := quarterWidget{
|
||||
color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
|
||||
}
|
||||
|
||||
var ops op.Ops
|
||||
for {
|
||||
e := w.Event()
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
// Clear background to white, even on embedded platforms such as webassembly.
|
||||
paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
|
||||
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
// r1c1
|
||||
layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
|
||||
// r1c2
|
||||
layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
// r2c1
|
||||
layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
|
||||
// r2c2
|
||||
layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
e.Frame(gtx.Ops)
|
||||
|
||||
switch notify {
|
||||
case notifyInvalidate:
|
||||
notify = notifyPrint
|
||||
w.Invalidate()
|
||||
case notifyPrint:
|
||||
notify = notifyNone
|
||||
fmt.Println("gio frame ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quarterWidget paints a quarter of the screen with one color. When clicked, it
|
||||
// turns red, going back to its normal color when clicked again.
|
||||
type quarterWidget struct {
|
||||
color color.NRGBA
|
||||
|
||||
clicked bool
|
||||
}
|
||||
|
||||
var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
|
||||
|
||||
func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
|
||||
var color color.NRGBA
|
||||
if w.clicked {
|
||||
color = red
|
||||
} else {
|
||||
color = w.color
|
||||
}
|
||||
|
||||
r := image.Rectangle{Max: gtx.Constraints.Max}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
|
||||
|
||||
defer clip.Rect(image.Rectangle{
|
||||
Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
|
||||
}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, w)
|
||||
filter := pointer.Filter{
|
||||
Target: w,
|
||||
Kinds: pointer.Press,
|
||||
}
|
||||
|
||||
for {
|
||||
e, ok := gtx.Event(filter)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
|
||||
w.clicked = !w.clicked
|
||||
// notify when we're done updating the frame.
|
||||
notify = notifyInvalidate
|
||||
}
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
minIOSVersion = 10
|
||||
// Some Metal features require tvOS 11
|
||||
minTVOSVersion = 11
|
||||
// Metal is available from iOS 8 on devices, yet from version 13 on the
|
||||
// simulator.
|
||||
minSimulatorVersion = 13
|
||||
)
|
||||
|
||||
func buildIOS(tmpDir, target string, bi *buildInfo) error {
|
||||
appName := bi.name
|
||||
switch *buildMode {
|
||||
case "archive":
|
||||
framework := *destPath
|
||||
if framework == "" {
|
||||
framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
|
||||
}
|
||||
return archiveIOS(tmpDir, target, framework, bi)
|
||||
case "exe":
|
||||
out := *destPath
|
||||
if out == "" {
|
||||
out = appName + ".ipa"
|
||||
}
|
||||
forDevice := strings.HasSuffix(out, ".ipa")
|
||||
// Filter out unsupported architectures.
|
||||
for i := len(bi.archs) - 1; i >= 0; i-- {
|
||||
switch bi.archs[i] {
|
||||
case "arm", "arm64":
|
||||
if forDevice {
|
||||
continue
|
||||
}
|
||||
case "386", "amd64":
|
||||
if !forDevice {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
|
||||
}
|
||||
if !forDevice && !strings.HasSuffix(out, ".app") {
|
||||
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
|
||||
}
|
||||
if !forDevice {
|
||||
return exeIOS(tmpDir, target, out, bi)
|
||||
}
|
||||
payload := filepath.Join(tmpDir, "Payload")
|
||||
appDir := filepath.Join(payload, appName+".app")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := signIOS(bi, tmpDir, appDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return zipDir(out, tmpDir, "Payload")
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func signIOS(bi *buildInfo, tmpDir, app string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
|
||||
provisions, err := filepath.Glob(provPattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provInfo := filepath.Join(tmpDir, "provision.plist")
|
||||
var avail []string
|
||||
for _, prov := range provisions {
|
||||
// Decode the provision file to a plist.
|
||||
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp, err := time.Parse(time.UnixDate, expUnix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
|
||||
}
|
||||
if exp.Before(time.Now()) {
|
||||
continue
|
||||
}
|
||||
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
|
||||
avail = append(avail, provAppID)
|
||||
if expAppID != provAppID {
|
||||
continue
|
||||
}
|
||||
// Copy provisioning file.
|
||||
embedded := filepath.Join(app, "embedded.mobileprovision")
|
||||
if err := copyFile(embedded, prov); err != nil {
|
||||
return err
|
||||
}
|
||||
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Omit trailing newline.
|
||||
certDER = certDER[:len(certDER)-1]
|
||||
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entFile := filepath.Join(tmpDir, "entitlements.plist")
|
||||
if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
|
||||
return err
|
||||
}
|
||||
identity := sha1.Sum(certDER)
|
||||
idHex := hex.EncodeToString(identity[:])
|
||||
_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
|
||||
}
|
||||
|
||||
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
|
||||
if bi.appID == "" {
|
||||
return errors.New("app id is empty; use -appid to set it")
|
||||
}
|
||||
if err := os.RemoveAll(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(app, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
appName := UppercaseName(bi.name)
|
||||
exe := filepath.Join(app, appName)
|
||||
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||
var builds errgroup.Group
|
||||
for _, a := range bi.archs {
|
||||
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cflags = append(cflags,
|
||||
"-fobjc-arc",
|
||||
)
|
||||
cflagsLine := strings.Join(cflags, " ")
|
||||
exeSlice := filepath.Join(tmpDir, "app-"+a)
|
||||
lipo.Args = append(lipo.Args, exeSlice)
|
||||
compile := exec.Command(
|
||||
"go",
|
||||
"build",
|
||||
"-ldflags=-s -w "+bi.ldflags,
|
||||
"-o", exeSlice,
|
||||
"-tags", bi.tags,
|
||||
bi.pkgPath,
|
||||
)
|
||||
compile.Env = append(
|
||||
os.Environ(),
|
||||
"GOOS=ios",
|
||||
"GOARCH="+a,
|
||||
"CGO_ENABLED=1",
|
||||
"CC="+clang,
|
||||
"CGO_CFLAGS="+cflagsLine,
|
||||
"CGO_LDFLAGS=-lresolv "+cflagsLine,
|
||||
)
|
||||
builds.Go(func() error {
|
||||
_, err := runCmd(compile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := builds.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runCmd(lipo); err != nil {
|
||||
return err
|
||||
}
|
||||
infoPlist := buildInfoPlist(bi)
|
||||
plistFile := filepath.Join(app, "Info.plist")
|
||||
if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Merge assets plist with Info.plist
|
||||
cmd := exec.Command(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
"-c", "Merge "+assetPlist,
|
||||
plistFile,
|
||||
)
|
||||
if _, err := runCmd(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
|
||||
// iosIcons returns the asset plist file to be merged into Info.plist.
|
||||
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
|
||||
assets := filepath.Join(tmpDir, "Assets.xcassets")
|
||||
if err := os.Mkdir(assets, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
appIcon := filepath.Join(assets, "AppIcon.appiconset")
|
||||
err := buildIcons(appIcon, icon, []iconVariant{
|
||||
{path: "ios_2x.png", size: 120},
|
||||
{path: "ios_3x.png", size: 180},
|
||||
// The App Store icon is not allowed to contain
|
||||
// transparent pixels.
|
||||
{path: "ios_store.png", size: 1024, fill: true},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
contentJson := `{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "ios_2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "ios_3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "ios_store.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
]
|
||||
}`
|
||||
contentFile := filepath.Join(appIcon, "Contents.json")
|
||||
if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
assetPlist := filepath.Join(tmpDir, "assets.plist")
|
||||
|
||||
minsdk := bi.minsdk
|
||||
if minsdk == 0 {
|
||||
minsdk = minIOSVersion
|
||||
}
|
||||
compile := exec.Command(
|
||||
"actool",
|
||||
"--compile", appDir,
|
||||
"--platform", iosPlatformFor(bi.target),
|
||||
"--minimum-deployment-target", strconv.Itoa(minsdk),
|
||||
"--app-icon", "AppIcon",
|
||||
"--output-partial-info-plist", assetPlist,
|
||||
assets)
|
||||
_, err = runCmd(compile)
|
||||
return assetPlist, err
|
||||
}
|
||||
|
||||
func buildInfoPlist(bi *buildInfo) string {
|
||||
appName := UppercaseName(bi.name)
|
||||
platform := iosPlatformFor(bi.target)
|
||||
var supportPlatform string
|
||||
switch bi.target {
|
||||
case "ios":
|
||||
supportPlatform = "iPhoneOS"
|
||||
case "tvos":
|
||||
supportPlatform = "AppleTVOS"
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>%s</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>%s</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>%s</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>%s</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>%d</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array><string>arm64</string></array>
|
||||
<key>DTPlatformName</key>
|
||||
<string>%s</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>12.4</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>%d</string>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>16G73</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>16G73</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>%s12.4</string>
|
||||
<key>DTXcode</key>
|
||||
<string>1030</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>10G8</string>
|
||||
</dict>
|
||||
</plist>`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
|
||||
}
|
||||
|
||||
func iosPlatformFor(target string) string {
|
||||
switch target {
|
||||
case "ios":
|
||||
return "iphoneos"
|
||||
case "tvos":
|
||||
return "appletvos"
|
||||
default:
|
||||
panic("invalid platform " + target)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
|
||||
framework := filepath.Base(frameworkRoot)
|
||||
const suf = ".framework"
|
||||
if !strings.HasSuffix(framework, suf) {
|
||||
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
|
||||
}
|
||||
framework = framework[:len(framework)-len(suf)]
|
||||
if err := os.RemoveAll(frameworkRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
|
||||
for _, dir := range []string{"Headers", "Modules"} {
|
||||
p := filepath.Join(frameworkDir, dir)
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
symlinks := [][2]string{
|
||||
{"Versions/Current/Headers", "Headers"},
|
||||
{"Versions/Current/Modules", "Modules"},
|
||||
{"Versions/Current/" + framework, framework},
|
||||
{"A", filepath.Join("Versions", "Current")},
|
||||
}
|
||||
for _, l := range symlinks {
|
||||
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exe := filepath.Join(frameworkDir, framework)
|
||||
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
|
||||
var builds errgroup.Group
|
||||
tags := bi.tags
|
||||
for _, a := range bi.archs {
|
||||
clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lib := filepath.Join(tmpDir, "gio-"+a)
|
||||
cmd := exec.Command(
|
||||
"go",
|
||||
"build",
|
||||
"-ldflags=-s -w "+bi.ldflags,
|
||||
"-buildmode=c-archive",
|
||||
"-o", lib,
|
||||
"-tags", tags,
|
||||
bi.pkgPath,
|
||||
)
|
||||
lipo.Args = append(lipo.Args, lib)
|
||||
cflagsLine := strings.Join(cflags, " ")
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GOOS=ios",
|
||||
"GOARCH="+a,
|
||||
"CGO_ENABLED=1",
|
||||
"CC="+clang,
|
||||
"CGO_CFLAGS="+cflagsLine,
|
||||
"CGO_LDFLAGS="+cflagsLine,
|
||||
)
|
||||
builds.Go(func() error {
|
||||
_, err := runCmd(cmd)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := builds.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runCmd(lipo); err != nil {
|
||||
return err
|
||||
}
|
||||
appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
|
||||
headerSrc := filepath.Join(appDir, "framework_ios.h")
|
||||
if err := copyFile(headerDst, headerSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
module := fmt.Sprintf(`framework module "%s" {
|
||||
header "%[1]s.h"
|
||||
|
||||
export *
|
||||
}`, framework)
|
||||
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
|
||||
return os.WriteFile(moduleFile, []byte(module), 0644)
|
||||
}
|
||||
|
||||
func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
|
||||
var (
|
||||
platformSDK string
|
||||
platformOS string
|
||||
)
|
||||
switch target {
|
||||
case "ios":
|
||||
platformOS = "ios"
|
||||
platformSDK = "iphone"
|
||||
case "tvos":
|
||||
platformOS = "tvos"
|
||||
platformSDK = "appletv"
|
||||
}
|
||||
switch arch {
|
||||
case "arm", "arm64":
|
||||
platformSDK += "os"
|
||||
if minsdk == 0 {
|
||||
minsdk = minIOSVersion
|
||||
if target == "tvos" {
|
||||
minsdk = minTVOSVersion
|
||||
}
|
||||
}
|
||||
case "386", "amd64":
|
||||
platformOS += "-simulator"
|
||||
platformSDK += "simulator"
|
||||
if minsdk == 0 {
|
||||
minsdk = minSimulatorVersion
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
|
||||
}
|
||||
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cflags := []string{
|
||||
"-fembed-bitcode",
|
||||
"-arch", allArchs[arch].iosArch,
|
||||
"-isysroot", sdkPath,
|
||||
"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
|
||||
}
|
||||
return clang, cflags, nil
|
||||
}
|
||||
|
||||
func zipDir(dst, base, dir string) (err error) {
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
zipf := zip.NewWriter(f)
|
||||
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel := filepath.ToSlash(path[len(base)+1:])
|
||||
entry, err := zipf.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
_, err = io.Copy(entry, src)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return zipf.Close()
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
|
||||
)
|
||||
|
||||
type JSTestDriver struct {
|
||||
driverBase
|
||||
|
||||
// ctx is the chromedp context.
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (d *JSTestDriver) Start(path string) {
|
||||
if raceEnabled {
|
||||
d.Skipf("js/wasm doesn't support -race; skipping")
|
||||
}
|
||||
|
||||
// First, build the app.
|
||||
dir := d.tempDir("gio-endtoend-js")
|
||||
d.gogio("-target=js", "-o="+dir, path)
|
||||
|
||||
// Second, start Chrome.
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", *headless),
|
||||
)
|
||||
|
||||
actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
d.Cleanup(cancel)
|
||||
|
||||
ctx, cancel := chromedp.NewContext(actx,
|
||||
// Send all logf/errf calls to t.Logf
|
||||
chromedp.WithLogf(d.Logf),
|
||||
)
|
||||
d.Cleanup(cancel)
|
||||
d.ctx = ctx
|
||||
|
||||
if err := chromedp.Run(ctx); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
d.Skipf("test requires Chrome to be installed: %v", err)
|
||||
return
|
||||
}
|
||||
d.Fatal(err)
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
d.Cleanup(func() { pw.Close() })
|
||||
d.output = pr
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventConsoleAPICalled:
|
||||
switch ev.Type {
|
||||
case "log", "info", "warning", "error":
|
||||
var b bytes.Buffer
|
||||
b.WriteString("console.")
|
||||
b.WriteString(string(ev.Type))
|
||||
b.WriteString("(")
|
||||
for i, arg := range ev.Args {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.Write(arg.Value)
|
||||
}
|
||||
b.WriteString(")\n")
|
||||
pw.Write(b.Bytes())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Third, serve the app folder, set the browser tab dimensions, and
|
||||
// navigate to the folder.
|
||||
ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
|
||||
d.Cleanup(ts.Close)
|
||||
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.EmulateViewport(int64(d.width), int64(d.height)),
|
||||
chromedp.Navigate(ts.URL),
|
||||
); err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for the gio app to render.
|
||||
d.waitForFrame()
|
||||
}
|
||||
|
||||
func (d *JSTestDriver) Screenshot() image.Image {
|
||||
var buf []byte
|
||||
if err := chromedp.Run(d.ctx,
|
||||
chromedp.CaptureScreenshot(&buf),
|
||||
); err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func (d *JSTestDriver) Click(x, y int) {
|
||||
if err := chromedp.Run(d.ctx,
|
||||
chromedp.MouseClickXY(float64(x), float64(y)),
|
||||
); err != nil {
|
||||
d.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for the gio app to render after this click.
|
||||
d.waitForFrame()
|
||||
}
|
||||