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 (
|
||||
"image"
|
||||
@@ -63,7 +63,7 @@ func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu la
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
type HeaderActionMetrics struct {
|
||||
type ActionMetrics struct {
|
||||
RowOriginX int
|
||||
Spacing int
|
||||
RowDims layout.Dimensions
|
||||
@@ -72,14 +72,14 @@ type HeaderActionMetrics struct {
|
||||
MainDims layout.Dimensions
|
||||
}
|
||||
|
||||
func (m HeaderActionMetrics) SyncAnchor() DropdownAnchor {
|
||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
||||
return DropdownAnchor{
|
||||
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
|
||||
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
|
||||
return DropdownAnchor{
|
||||
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/apitokens"
|
||||
"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/passwords"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
@@ -260,13 +261,13 @@ func TestUIListPanelTopSectionsMatchAcrossDesktopAndPhoneForEntries(t *testing.T
|
||||
phone := newUIWithModel("phone", vault.Model{})
|
||||
phone.state.Section = appstate.SectionEntries
|
||||
|
||||
want := []listPanelTopSection{
|
||||
listPanelTopSearch,
|
||||
listPanelTopNavigation,
|
||||
listPanelTopPath,
|
||||
listPanelTopGroup,
|
||||
listPanelTopGroupTools,
|
||||
listPanelTopPrimary,
|
||||
want := []listlayout.TopSection{
|
||||
listlayout.TopSearch,
|
||||
listlayout.TopNavigation,
|
||||
listlayout.TopPath,
|
||||
listlayout.TopGroup,
|
||||
listlayout.TopGroupTools,
|
||||
listlayout.TopPrimary,
|
||||
}
|
||||
if got := desktop.listPanelTopSections(); !slices.Equal(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) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -384,10 +385,10 @@ func TestAnchoredMenuXAllowsWiderMenusToExtendLeft(t *testing.T) {
|
||||
func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -395,7 +396,7 @@ func TestAnchoredMenuOriginXClampsToVisibleContainer(t *testing.T) {
|
||||
func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := appuilayout.HeaderActionMetrics{
|
||||
metrics := headerlayout.ActionMetrics{
|
||||
RowOriginX: 24,
|
||||
Spacing: 8,
|
||||
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)},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -415,14 +416,14 @@ func TestHeaderActionMetricsComputeTriggerAnchors(t *testing.T) {
|
||||
func TestDropdownSurfaceOriginKeepsMenusWithinVisibleArea(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
surface := appuilayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
|
||||
anchor := appuilayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
|
||||
surface := headerlayout.DropdownSurface{ContainerWidth: 320, LeftInset: 16, TopInset: 16}
|
||||
anchor := headerlayout.DropdownAnchor{TriggerRightX: 300, TriggerBottomY: 42}
|
||||
|
||||
if got := surface.Origin(anchor, 140); got != image.Pt(176, 58) {
|
||||
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) {
|
||||
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/material"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
spacing := gtx.Dp(unit.Dp(8))
|
||||
metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
|
||||
metrics := headerlayout.ActionMetrics{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 {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
rowCall.Add(gtx.Ops)
|
||||
@@ -478,17 +478,17 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
contentInsetPx := gtx.Dp(unit.Dp(16))
|
||||
surface := appuilayout.DropdownSurface{
|
||||
surface := headerlayout.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)
|
||||
surface.Draw(gtx, headerlayout.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)
|
||||
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
||||
}
|
||||
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