Split app UI layout packages
This commit is contained in:
+32
-4102
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
package detail
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeLocked Mode = "locked"
|
||||||
|
ModeStatic Mode = "static"
|
||||||
|
ModeEmpty Mode = "empty"
|
||||||
|
ModeEditor Mode = "editor"
|
||||||
|
ModeView Mode = "view"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
||||||
|
switch {
|
||||||
|
case isLocked:
|
||||||
|
return ModeLocked
|
||||||
|
case hasStaticPanel:
|
||||||
|
return ModeStatic
|
||||||
|
case !hasSelectedEntry && !editing:
|
||||||
|
return ModeEmpty
|
||||||
|
case editing:
|
||||||
|
return ModeEditor
|
||||||
|
default:
|
||||||
|
return ModeView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package layout
|
package header
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
@@ -63,7 +63,7 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la
|
|||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeaderActionMetrics struct {
|
type ActionMetrics struct {
|
||||||
RowOriginX int
|
RowOriginX int
|
||||||
Spacing int
|
Spacing int
|
||||||
RowDims layout.Dimensions
|
RowDims layout.Dimensions
|
||||||
@@ -72,14 +72,14 @@ type HeaderActionMetrics struct {
|
|||||||
MainDims layout.Dimensions
|
MainDims layout.Dimensions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor {
|
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
||||||
return DropdownAnchor{
|
return DropdownAnchor{
|
||||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
||||||
TriggerBottomY: m.RowDims.Size.Y,
|
TriggerBottomY: m.RowDims.Size.Y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m HeaderActionMetrics) MainAnchor() DropdownAnchor {
|
func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
||||||
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
|
||||||
return DropdownAnchor{
|
return DropdownAnchor{
|
||||||
TriggerRightX: m.RowOriginX + triggerRightX,
|
TriggerRightX: m.RowOriginX + triggerRightX,
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
type TopSection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopSearch TopSection = "search"
|
||||||
|
TopNavigation TopSection = "navigation"
|
||||||
|
TopPath TopSection = "path"
|
||||||
|
TopGroup TopSection = "group"
|
||||||
|
TopGroupTools TopSection = "group_tools"
|
||||||
|
TopPrimary TopSection = "primary"
|
||||||
|
)
|
||||||
+19
-18
@@ -25,7 +25,8 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header"
|
||||||
|
listlayout "git.julianfamily.org/keepassgo/internal/appui/layout/list"
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
@@ -260,13 +261,13 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T
|
|||||||
phone := newUIWithModel("phone", vault.Model{})
|
phone := newUIWithModel("phone", vault.Model{})
|
||||||
phone.state.Section = appstate.SectionEntries
|
phone.state.Section = appstate.SectionEntries
|
||||||
|
|
||||||
want := []listPanelTopSection{
|
want := []listlayout.TopSection{
|
||||||
listPanelTopSearch,
|
listlayout.TopSearch,
|
||||||
listPanelTopNavigation,
|
listlayout.TopNavigation,
|
||||||
listPanelTopPath,
|
listlayout.TopPath,
|
||||||
listPanelTopGroup,
|
listlayout.TopGroup,
|
||||||
listPanelTopGroupTools,
|
listlayout.TopGroupTools,
|
||||||
listPanelTopPrimary,
|
listlayout.TopPrimary,
|
||||||
}
|
}
|
||||||
if got := desktop.listPanelTopSections(); !slices.Equal(got, want) {
|
if got := desktop.listPanelTopSections(); !slices.Equal(got, want) {
|
||||||
t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want)
|
t.Fatalf("desktop.listPanelTopSections() = %v, want %v", got, want)
|
||||||
@@ -373,10 +374,10 @@ func TestUIHeaderMenusUseOverlayModelAcrossModes(t *testing.T) {
|
|||||||
func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
|
func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if got := appuilayout.AnchoredMenuX(48, 160); got != -112 {
|
if got := headerlayout.AnchoredMenuX(48, 160); got != -112 {
|
||||||
t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got)
|
t.Fatalf("anchoredMenuX(48, 160) = %d, want -112", got)
|
||||||
}
|
}
|
||||||
if got := appuilayout.AnchoredMenuX(160, 48); got != 112 {
|
if got := headerlayout.AnchoredMenuX(160, 48); got != 112 {
|
||||||
t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got)
|
t.Fatalf("anchoredMenuX(160, 48) = %d, want 112", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,10 +385,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
|
|||||||
func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
|
func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if got := appuilayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 {
|
if got := headerlayout.AnchoredMenuOriginX(360, 312, 360, 140); got != 220 {
|
||||||
t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got)
|
t.Fatalf("anchoredMenuOriginX should keep a right-aligned menu visible, got %d want 220", got)
|
||||||
}
|
}
|
||||||
if got := appuilayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 {
|
if got := headerlayout.AnchoredMenuOriginX(360, 0, 44, 160); got != 0 {
|
||||||
t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got)
|
t.Fatalf("anchoredMenuOriginX should clamp oversized left overflow to zero, got %d want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +396,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
|
|||||||
func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
metrics := appuilayout.HeaderActionMetrics{
|
metrics := headerlayout.ActionMetrics{
|
||||||
RowOriginX: 24,
|
RowOriginX: 24,
|
||||||
Spacing: 8,
|
Spacing: 8,
|
||||||
RowDims: layout.Dimensions{Size: image.Pt(180, 40)},
|
RowDims: layout.Dimensions{Size: image.Pt(180, 40)},
|
||||||
@@ -404,10 +405,10 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
|||||||
MainDims: layout.Dimensions{Size: image.Pt(36, 40)},
|
MainDims: layout.Dimensions{Size: image.Pt(36, 40)},
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := metrics.SyncAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) {
|
if got := metrics.SyncAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 76, TriggerBottomY: 40}) {
|
||||||
t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got)
|
t.Fatalf("metrics.syncAnchor() = %+v, want right=76 bottom=40", got)
|
||||||
}
|
}
|
||||||
if got := metrics.MainAnchor(); got != (appuilayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) {
|
if got := metrics.MainAnchor(); got != (headerlayout.DropdownAnchor{TriggerRightX: 172, TriggerBottomY: 40}) {
|
||||||
t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got)
|
t.Fatalf("metrics.mainAnchor() = %+v, want right=172 bottom=40", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,14 +416,14 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
|||||||
func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) {
|
func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
|
surface := headerlayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
|
||||||
anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
|
anchor := headerlayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
|
||||||
|
|
||||||
if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) {
|
if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) {
|
||||||
t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got)
|
t.Fatalf("surface.origin(anchor, 140) = %v, want (176,58)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
leftAnchor := appuilayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42}
|
leftAnchor := headerlayout.DropdownAnchor{TriggerRightX: 36, TriggerBottomY: 42}
|
||||||
if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) {
|
if got := surface.Origin(leftAnchor, 120); got != image.Pt(16, 58) {
|
||||||
t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got)
|
t.Fatalf("surface.origin(leftAnchor, 120) = %v, want (16,58)", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,778 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/x/explorer"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) createVaultAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
||||||
|
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||||
|
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.CreateVault(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.lifecycleMode == "local" {
|
||||||
|
u.selectedVaultRemoteProfileID = ""
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = ""
|
||||||
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||||
|
u.remoteBaseURL.SetText("")
|
||||||
|
u.remotePath.SetText("")
|
||||||
|
u.remoteUsername.SetText("")
|
||||||
|
u.remotePassword.SetText("")
|
||||||
|
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.vaultPath.SetText(u.saveAsTargetPath())
|
||||||
|
u.noteRecentVault(u.saveAsTargetPath())
|
||||||
|
}
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openVaultAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.OpenVault(path, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.restoreRecentVaultGroup(path)
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||||
|
}
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
u.applyPendingLifecycleOpenIntent()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startOpenVaultAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("open vault", u.openVaultAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.lastLifecycleAction = "open vault"
|
||||||
|
u.runBackgroundAction("open vault", func() (func() error, error) {
|
||||||
|
prepared, err := session.PrepareLocalOpen(path, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedLocalOpen(prepared)
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.restoreRecentVaultGroup(path)
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||||
|
}
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
u.applyPendingLifecycleOpenIntent()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return "Open Vault And Set Up Remote Sync"
|
||||||
|
}
|
||||||
|
if hasBoundRecentRemote(u.recentRemotes, path) {
|
||||||
|
return "Open Vault And Open Remote Sync Settings"
|
||||||
|
}
|
||||||
|
return "Open Vault And Set Up Remote Sync"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
switch {
|
||||||
|
case path == "":
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||||
|
case hasBoundRecentRemote(u.recentRemotes, path):
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
||||||
|
default:
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
||||||
|
}
|
||||||
|
u.startOpenVaultAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyPendingLifecycleOpenIntent() {
|
||||||
|
intent := u.pendingLifecycleOpenIntent
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||||
|
switch intent {
|
||||||
|
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
||||||
|
u.openRemoteSyncSetupDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAction() error {
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAsAction() error {
|
||||||
|
path := u.saveAsTargetPath()
|
||||||
|
if err := u.state.SaveAs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.vaultPath.SetText(path)
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openRemoteAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
||||||
|
return err
|
||||||
|
} else if ok {
|
||||||
|
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
|
Password: u.remotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(
|
||||||
|
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
strings.TrimSpace(u.remotePath.Text()),
|
||||||
|
)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startOpenRemoteAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("open remote vault", u.openRemoteAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
|
Password: u.remotePassword.Text(),
|
||||||
|
}
|
||||||
|
remotePath := strings.TrimSpace(u.remotePath.Text())
|
||||||
|
u.lastLifecycleAction = "open remote vault"
|
||||||
|
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
||||||
|
binding, bindingOK := u.selectedVaultRemoteBinding()
|
||||||
|
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
||||||
|
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved, err := binding.Resolve(preparedLocal.Model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}, resolved.Profile.Path, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedLocalOpen(preparedLocal)
|
||||||
|
u.vaultPath.SetText(binding.LocalVaultPath)
|
||||||
|
u.noteRecentVault(binding.LocalVaultPath)
|
||||||
|
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
||||||
|
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if u.hasOpenVault() {
|
||||||
|
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
client = webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
remotePath = resolved.Profile.Path
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedRemoteOpen(prepared)
|
||||||
|
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(
|
||||||
|
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
remotePath,
|
||||||
|
)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lockAction() error {
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err := u.state.Lock(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) unlockAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.Unlock(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startUnlockAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("unlock vault", u.unlockAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
||||||
|
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
||||||
|
prepared, err := session.PrepareUnlock(encoded, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedUnlock(prepared)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) changeMasterKeyAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.state.ChangeMasterKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadSecuritySettingsFromSession() {
|
||||||
|
settings, err := u.state.SecuritySettings()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.securityCipher.SetText(settings.Cipher)
|
||||||
|
u.securityKDF.SetText(settings.KDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearMasterPassword() {
|
||||||
|
u.masterPassword.SetText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) synchronizeAction() error {
|
||||||
|
if err := u.state.Synchronize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openAdvancedSyncDialog() {
|
||||||
|
u.syncDialogOpen = true
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
|
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||||
|
u.syncSourceMode = u.syncDefaultSourceMode
|
||||||
|
u.syncDirection = u.syncDefaultDirection
|
||||||
|
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||||
|
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||||
|
}
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openRemoteSyncSetupDialog() {
|
||||||
|
u.syncDialogOpen = true
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
|
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||||
|
u.syncSourceMode = syncSourceRemote
|
||||||
|
u.syncDirection = syncDirectionPush
|
||||||
|
u.syncSetupAutomatic.Value = true
|
||||||
|
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||||
|
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||||
|
}
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||||
|
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
||||||
|
u.syncSetupAutomatic.Value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearSyncLocalImport() {
|
||||||
|
u.syncLocalImportName = ""
|
||||||
|
u.syncLocalImportContent = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||||
|
name := strings.TrimSpace(u.syncLocalImportName)
|
||||||
|
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||||
|
switch mode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
return syncSourceRemote
|
||||||
|
default:
|
||||||
|
return syncSourceLocal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
||||||
|
switch direction {
|
||||||
|
case syncDirectionPush:
|
||||||
|
return syncDirectionPush
|
||||||
|
default:
|
||||||
|
return syncDirectionPull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncAction() error {
|
||||||
|
switch u.syncDirection {
|
||||||
|
case syncDirectionPush:
|
||||||
|
return u.advancedSyncToAction()
|
||||||
|
default:
|
||||||
|
return u.advancedSyncFromAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncFromAction() error {
|
||||||
|
switch u.syncSourceMode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||||
|
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.syncDialogOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||||
|
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||||
|
u.runAction("choose sync path", func() error {
|
||||||
|
u.clearSyncLocalImport()
|
||||||
|
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
label := "Selected Android vault"
|
||||||
|
return func() error {
|
||||||
|
u.syncLocalImportName = label
|
||||||
|
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||||
|
u.syncLocalPath.SetText(label)
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
||||||
|
if named, ok := file.(interface{ Name() string }); ok {
|
||||||
|
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback = filepath.Base(strings.TrimSpace(fallback))
|
||||||
|
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
||||||
|
return "selected-vault.kdbx"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startChooseVaultPathAction() {
|
||||||
|
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||||
|
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := pickedDocumentName(file, "selected-vault.kdbx")
|
||||||
|
return func() error {
|
||||||
|
return u.importSharedVaultBytesAction(name, content)
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startImportSharedVaultAction() {
|
||||||
|
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncToAction() error {
|
||||||
|
switch u.syncSourceMode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
||||||
|
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||||
|
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.showStatusMessage("Remote sync is set up for this vault.")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.syncDialogOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
remotePath = strings.TrimSpace(remotePath)
|
||||||
|
if baseURL == "" || remotePath == "" {
|
||||||
|
return fmt.Errorf("remote setup requires base URL and path")
|
||||||
|
}
|
||||||
|
input := appstate.RemoteBindingInput{
|
||||||
|
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||||
|
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||||
|
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
||||||
|
BaseURL: baseURL,
|
||||||
|
RemotePath: remotePath,
|
||||||
|
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||||
|
CredentialTitle: "WebDAV Sign-In" + func() string {
|
||||||
|
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
||||||
|
return " · " + user
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
CredentialPath: append([]string(nil), u.currentPath...),
|
||||||
|
SyncMode: u.syncSetupMode(),
|
||||||
|
}
|
||||||
|
binding, err := u.state.ConfigureRemoteBinding(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||||
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||||
|
u.remoteBaseURL.SetText(baseURL)
|
||||||
|
u.remotePath.SetText(remotePath)
|
||||||
|
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
||||||
|
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
||||||
|
u.noteRecentRemote(baseURL, remotePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAsTargetPath() string {
|
||||||
|
path := strings.TrimSpace(u.saveAsPath.Text())
|
||||||
|
if path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return u.defaultSaveAsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) importedVaultDestination(name string) string {
|
||||||
|
baseTarget := u.saveAsTargetPath()
|
||||||
|
baseDir := filepath.Dir(baseTarget)
|
||||||
|
baseName := filepath.Base(strings.TrimSpace(name))
|
||||||
|
switch {
|
||||||
|
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||||
|
return baseTarget
|
||||||
|
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||||
|
return filepath.Join(baseDir, baseName)
|
||||||
|
default:
|
||||||
|
return baseTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) consumePendingSharedVaultImport() {
|
||||||
|
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := "shared-vault.kdbx"
|
||||||
|
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||||
|
if rawName, err := os.ReadFile(namePath); err == nil {
|
||||||
|
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
||||||
|
name = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
||||||
|
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||||
|
_ = os.Remove(namePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||||
|
target := u.importedVaultDestination(name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.lifecycleMode = "local"
|
||||||
|
u.vaultPath.SetText(target)
|
||||||
|
u.noteRecentVault(target)
|
||||||
|
u.state.ErrorMessage = ""
|
||||||
|
u.state.StatusMessage = ""
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) currentShareableVaultPath() string {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) shareCurrentVaultAction() error {
|
||||||
|
if u.vaultSharer == nil {
|
||||||
|
return fmt.Errorf("vault sharing is not available on this platform")
|
||||||
|
}
|
||||||
|
path := u.currentShareableVaultPath()
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import (
|
|||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/actions"
|
"git.julianfamily.org/keepassgo/internal/appui/actions"
|
||||||
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/layout/header"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
spacing := gtx.Dp(unit.Dp(8))
|
||||||
metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
|
metrics := headerlayout.ActionMetrics{Spacing: spacing}
|
||||||
row := func(gtx layout.Context) layout.Dimensions {
|
row := func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -75,7 +75,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|||||||
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
||||||
}
|
}
|
||||||
|
|
||||||
surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
||||||
|
|
||||||
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
|
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
|
||||||
rowCall.Add(gtx.Ops)
|
rowCall.Add(gtx.Ops)
|
||||||
@@ -478,17 +478,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
gtx.Constraints.Min = gtx.Constraints.Max
|
gtx.Constraints.Min = gtx.Constraints.Max
|
||||||
contentInsetPx := gtx.Dp(unit.Dp(16))
|
contentInsetPx := gtx.Dp(unit.Dp(16))
|
||||||
surface := appuilayout.DropdownSurface{
|
surface := headerlayout.DropdownSurface{
|
||||||
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
||||||
LeftInset: contentInsetPx,
|
LeftInset: contentInsetPx,
|
||||||
TopInset: contentInsetPx,
|
TopInset: contentInsetPx,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.syncMenuVisibleOnPhone() {
|
if u.syncMenuVisibleOnPhone() {
|
||||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
||||||
}
|
}
|
||||||
if u.mainMenuVisibleOnPhone() {
|
if u.mainMenuVisibleOnPhone() {
|
||||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
||||||
}
|
}
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/x/explorer"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/api"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Main() {
|
||||||
|
mode := flag.String("mode", "", "window mode: desktop or phone")
|
||||||
|
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
||||||
|
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
||||||
|
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
||||||
|
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
||||||
|
|
||||||
|
width := unit.Dp(1180)
|
||||||
|
height := unit.Dp(760)
|
||||||
|
if strings.EqualFold(resolvedMode, "phone") {
|
||||||
|
width = unit.Dp(412)
|
||||||
|
height = unit.Dp(915)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
w := new(app.Window)
|
||||||
|
options := []app.Option{app.Title(productName)}
|
||||||
|
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
|
||||||
|
options = append(options, app.Size(width, height))
|
||||||
|
}
|
||||||
|
w.Option(options...)
|
||||||
|
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(runtime.GOOS, "android") {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultGRPCAddr(goos string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
return "127.0.0.1:47777"
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||||
|
var ops op.Ops
|
||||||
|
manager := &session.Manager{}
|
||||||
|
ui := newUIWithSession(mode, manager, paths)
|
||||||
|
ui.fileExplorer = explorer.NewExplorer(w)
|
||||||
|
ui.invalidate = w.Invalidate
|
||||||
|
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
|
||||||
|
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||||
|
if err != nil {
|
||||||
|
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||||
|
} else if host != nil {
|
||||||
|
ui.apiHost = host
|
||||||
|
ui.auditLog = host.Server().AuditLog()
|
||||||
|
ui.grpcAddress = host.Address()
|
||||||
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
|
defer func() { _ = host.Stop() }()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
e := w.Event()
|
||||||
|
ui.fileExplorer.ListenEvents(e)
|
||||||
|
switch e := e.(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return e.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
gtx := app.NewContext(&ops, e)
|
||||||
|
ui.processBackgroundActions()
|
||||||
|
ui.layout(gtx)
|
||||||
|
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
|
||||||
|
e.Frame(gtx.Ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type uiApprovalManager struct {
|
||||||
|
server *api.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
||||||
|
if m == nil || m.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.server.ApprovalBroker().Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
|
if m == nil || m.server == nil {
|
||||||
|
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
||||||
|
}
|
||||||
|
return m.server.ResolveApproval(id, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uiSession struct {
|
||||||
|
model vault.Model
|
||||||
|
locked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) HasVault() bool {
|
||||||
|
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) IsLocked() bool {
|
||||||
|
return s.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) IsRemote() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) Current() (vault.Model, error) {
|
||||||
|
if s.locked {
|
||||||
|
return vault.Model{}, session.ErrLocked
|
||||||
|
}
|
||||||
|
return s.model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) Replace(model vault.Model) {
|
||||||
|
s.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) Lock() error {
|
||||||
|
s.locked = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *uiSession) Unlock(vault.MasterKey) error {
|
||||||
|
if !s.locked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.locked = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickExistingFile() (string, error) {
|
||||||
|
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFilePicker(name string, args ...string) (string, error) {
|
||||||
|
if _, err := exec.LookPath(name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parsePickedFilePath(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePickedFilePath(output []byte) (string, error) {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("file picker did not return a path")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user