Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1cd7876c | |||
| ccaee9fa34 |
@@ -1,95 +0,0 @@
|
|||||||
package actions
|
|
||||||
|
|
||||||
import "git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
|
|
||||||
type SyncMenuModel struct {
|
|
||||||
HasOpenVault bool
|
|
||||||
HasSelectedBinding bool
|
|
||||||
ShowSelectors bool
|
|
||||||
ShowShare bool
|
|
||||||
ShowSaveCurrentBinding bool
|
|
||||||
SavedBindingSummary SyncMenuBindingSummary
|
|
||||||
RemoteBaseURL string
|
|
||||||
RemotePath string
|
|
||||||
RemoteUsername string
|
|
||||||
RemotePassword string
|
|
||||||
SelectedVaultSyncMode appstate.SyncMode
|
|
||||||
}
|
|
||||||
|
|
||||||
type SyncMenuBindingSummary struct {
|
|
||||||
ProfileLabel string
|
|
||||||
CredentialLabel string
|
|
||||||
SyncLabel string
|
|
||||||
OK bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) SavedBindingHeading() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use this vault's saved remote sync target"
|
|
||||||
}
|
|
||||||
return "Use a saved remote profile from this vault"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) OpenSelectedButtonLabel() string {
|
|
||||||
if !m.ShowSelectors {
|
|
||||||
return "Use Remote Sync"
|
|
||||||
}
|
|
||||||
return "Open Saved Remote"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) ShowDirectRemoteSyncShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) DirectRemoteSyncShortcutLabel() string {
|
|
||||||
return "Use Remote Sync"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
|
||||||
return m.HasOpenVault && m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) RemoteSyncSettingsShortcutLabel() string {
|
|
||||||
return "Remote Sync Settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) ShowRemoveRemoteSyncShortcut() bool {
|
|
||||||
return m.ShowRemoteSyncSettingsShortcut()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) RemoveRemoteSyncShortcutLabel() string {
|
|
||||||
return "Stop Using Remote Sync"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) ShowRemoteSyncSetupShortcut() bool {
|
|
||||||
return m.HasOpenVault && !m.HasSelectedBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) RemoteSyncSetupShortcutLabel() string {
|
|
||||||
return "Set Up Remote Sync"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) ActionLabels() []string {
|
|
||||||
labels := []string{"Open Advanced Sync"}
|
|
||||||
if m.ShowRemoteSyncSetupShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowDirectRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoteSyncSettingsShortcut() {
|
|
||||||
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
|
||||||
}
|
|
||||||
if m.ShowRemoveRemoteSyncShortcut() {
|
|
||||||
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) SaveCurrentRemoteBindingHeading() string {
|
|
||||||
return "Bind this local vault to the current remote target"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m SyncMenuModel) SaveCurrentRemoteBindingButtonLabel() string {
|
|
||||||
return "Save Remote In Vault"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditQuickFilter struct {
|
||||||
|
Label string
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Operations() []apitokens.Operation {
|
||||||
|
return []apitokens.Operation{
|
||||||
|
apitokens.OperationListEntries,
|
||||||
|
apitokens.OperationListGroups,
|
||||||
|
apitokens.OperationReadEntry,
|
||||||
|
apitokens.OperationCopyPassword,
|
||||||
|
apitokens.OperationCopyUsername,
|
||||||
|
apitokens.OperationCopyURL,
|
||||||
|
apitokens.OperationMutateEntry,
|
||||||
|
apitokens.OperationMutateGroup,
|
||||||
|
apitokens.OperationManageVault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuditDecisionLabel(eventType apiaudit.EventType) string {
|
||||||
|
switch eventType {
|
||||||
|
case apiaudit.EventApprovalRequested:
|
||||||
|
return "Requested"
|
||||||
|
case apiaudit.EventApprovalAllowed:
|
||||||
|
return "Allowed"
|
||||||
|
case apiaudit.EventApprovalDenied:
|
||||||
|
return "Denied"
|
||||||
|
case apiaudit.EventApprovalCanceled:
|
||||||
|
return "Canceled"
|
||||||
|
case apiaudit.EventApprovalTimedOut:
|
||||||
|
return "Timed Out"
|
||||||
|
case apiaudit.EventAuthRejected:
|
||||||
|
return "Auth Rejected"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(string(eventType), "_", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuditOperationLabel(operation apitokens.Operation) string {
|
||||||
|
if strings.TrimSpace(string(operation)) == "" {
|
||||||
|
return "Other"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(string(operation), "_", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompactAuditFilterLabel(label string) string {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if len(label) <= 22 {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return label[:19] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuditEventSearchTerms(event apiaudit.Event) string {
|
||||||
|
parts := []string{
|
||||||
|
string(event.Type),
|
||||||
|
AuditDecisionLabel(event.Type),
|
||||||
|
event.TokenName,
|
||||||
|
event.ClientName,
|
||||||
|
string(event.Operation),
|
||||||
|
AuditOperationLabel(event.Operation),
|
||||||
|
strings.Join(event.Resource.Path, " / "),
|
||||||
|
event.Resource.EntryID,
|
||||||
|
event.Message,
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case apiaudit.EventApprovalAllowed:
|
||||||
|
parts = append(parts, "allow approved")
|
||||||
|
case apiaudit.EventApprovalDenied:
|
||||||
|
parts = append(parts, "deny denied")
|
||||||
|
case apiaudit.EventApprovalRequested:
|
||||||
|
parts = append(parts, "prompt requested")
|
||||||
|
case apiaudit.EventApprovalCanceled:
|
||||||
|
parts = append(parts, "cancel canceled")
|
||||||
|
case apiaudit.EventApprovalTimedOut:
|
||||||
|
parts = append(parts, "timeout timed out")
|
||||||
|
case apiaudit.EventAuthRejected:
|
||||||
|
parts = append(parts, "rejected unauthorized")
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
@@ -12,89 +12,10 @@ import (
|
|||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func apiOperations() []apitokens.Operation {
|
type apiAuditQuickFilter = apiui.AuditQuickFilter
|
||||||
return []apitokens.Operation{
|
|
||||||
apitokens.OperationListEntries,
|
|
||||||
apitokens.OperationListGroups,
|
|
||||||
apitokens.OperationReadEntry,
|
|
||||||
apitokens.OperationCopyPassword,
|
|
||||||
apitokens.OperationCopyUsername,
|
|
||||||
apitokens.OperationCopyURL,
|
|
||||||
apitokens.OperationMutateEntry,
|
|
||||||
apitokens.OperationMutateGroup,
|
|
||||||
apitokens.OperationManageVault,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiAuditQuickFilter struct {
|
|
||||||
Label string
|
|
||||||
Query string
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
|
|
||||||
switch eventType {
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
return "Requested"
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
return "Allowed"
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
return "Denied"
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
return "Canceled"
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
return "Timed Out"
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
return "Auth Rejected"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(eventType), "_", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditOperationLabel(operation apitokens.Operation) string {
|
|
||||||
if strings.TrimSpace(string(operation)) == "" {
|
|
||||||
return "Other"
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(string(operation), "_", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactAuditFilterLabel(label string) string {
|
|
||||||
label = strings.TrimSpace(label)
|
|
||||||
if len(label) <= 22 {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return label[:19] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditEventSearchTerms(event apiaudit.Event) string {
|
|
||||||
parts := []string{
|
|
||||||
string(event.Type),
|
|
||||||
apiAuditDecisionLabel(event.Type),
|
|
||||||
event.TokenName,
|
|
||||||
event.ClientName,
|
|
||||||
string(event.Operation),
|
|
||||||
apiAuditOperationLabel(event.Operation),
|
|
||||||
strings.Join(event.Resource.Path, " / "),
|
|
||||||
event.Resource.EntryID,
|
|
||||||
event.Message,
|
|
||||||
}
|
|
||||||
switch event.Type {
|
|
||||||
case apiaudit.EventApprovalAllowed:
|
|
||||||
parts = append(parts, "allow approved")
|
|
||||||
case apiaudit.EventApprovalDenied:
|
|
||||||
parts = append(parts, "deny denied")
|
|
||||||
case apiaudit.EventApprovalRequested:
|
|
||||||
parts = append(parts, "prompt requested")
|
|
||||||
case apiaudit.EventApprovalCanceled:
|
|
||||||
parts = append(parts, "cancel canceled")
|
|
||||||
case apiaudit.EventApprovalTimedOut:
|
|
||||||
parts = append(parts, "timeout timed out")
|
|
||||||
case apiaudit.EventAuthRejected:
|
|
||||||
parts = append(parts, "rejected unauthorized")
|
|
||||||
}
|
|
||||||
return strings.ToLower(strings.Join(parts, " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
|
||||||
if len(filters) == 0 {
|
if len(filters) == 0 {
|
||||||
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
|||||||
}
|
}
|
||||||
if _, ok := decisionSeen[event.Type]; !ok {
|
if _, ok := decisionSeen[event.Type]; !ok {
|
||||||
decisionSeen[event.Type] = struct{}{}
|
decisionSeen[event.Type] = struct{}{}
|
||||||
label := apiAuditDecisionLabel(event.Type)
|
label := apiui.AuditDecisionLabel(event.Type)
|
||||||
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(event.Operation)) == "" {
|
if strings.TrimSpace(string(event.Operation)) == "" {
|
||||||
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
operationSeen[event.Operation] = struct{}{}
|
operationSeen[event.Operation] = struct{}{}
|
||||||
label := apiAuditOperationLabel(event.Operation)
|
label := apiui.AuditOperationLabel(event.Operation)
|
||||||
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
|
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +242,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) {
|
|||||||
|
|
||||||
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
||||||
value := apitokens.Operation(strings.TrimSpace(text))
|
value := apitokens.Operation(strings.TrimSpace(text))
|
||||||
for _, operation := range apiOperations() {
|
for _, operation := range apiui.Operations() {
|
||||||
if operation == value {
|
if operation == value {
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -450,7 +371,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
|||||||
}
|
}
|
||||||
filtered := make([]apiaudit.Event, 0, len(events))
|
filtered := make([]apiaudit.Event, 0, len(events))
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
haystack := apiAuditEventSearchTerms(event)
|
haystack := apiui.AuditEventSearchTerms(event)
|
||||||
if strings.Contains(haystack, query) {
|
if strings.Contains(haystack, query) {
|
||||||
filtered = append(filtered, event)
|
filtered = append(filtered, event)
|
||||||
}
|
}
|
||||||
@@ -785,7 +706,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
|||||||
click := &buttons[i]
|
click := &buttons[i]
|
||||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||||
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
|
||||||
@@ -799,7 +720,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
|
|||||||
click := &buttons[i]
|
click := &buttons[i]
|
||||||
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
|
||||||
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
|
return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
|
||||||
@@ -1051,7 +972,7 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
|
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.apiPolicyGroupScopeW.Value {
|
if u.apiPolicyGroupScopeW.Value {
|
||||||
+54
-4146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeLocked Mode = "locked"
|
||||||
|
ModeStatic Mode = "static"
|
||||||
|
ModeEmpty Mode = "empty"
|
||||||
|
ModeEditor Mode = "editor"
|
||||||
|
ModeView Mode = "view"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
|
||||||
|
switch {
|
||||||
|
case isLocked:
|
||||||
|
return ModeLocked
|
||||||
|
case hasStaticPanel:
|
||||||
|
return ModeStatic
|
||||||
|
case !hasSelectedEntry && !editing:
|
||||||
|
return ModeEmpty
|
||||||
|
case editing:
|
||||||
|
return ModeEditor
|
||||||
|
default:
|
||||||
|
return ModeView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package detail
|
||||||
|
|
||||||
|
type EmptyState struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultSummary struct {
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttachmentItem struct {
|
||||||
|
Name string
|
||||||
|
Size int
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Field string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldID Field = "id"
|
||||||
|
FieldTitle Field = "title"
|
||||||
|
FieldUsername Field = "username"
|
||||||
|
FieldPassword Field = "password"
|
||||||
|
FieldURL Field = "url"
|
||||||
|
FieldPath Field = "path"
|
||||||
|
FieldTags Field = "tags"
|
||||||
|
FieldPasswordProfile Field = "password-profile"
|
||||||
|
FieldNotes Field = "notes"
|
||||||
|
FieldFields Field = "fields"
|
||||||
|
FieldHistoryIndex Field = "history-index"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Label(field Field) string {
|
||||||
|
switch field {
|
||||||
|
case FieldID:
|
||||||
|
return "ID"
|
||||||
|
case FieldTitle:
|
||||||
|
return "Title"
|
||||||
|
case FieldUsername:
|
||||||
|
return "Username"
|
||||||
|
case FieldPassword:
|
||||||
|
return "Password"
|
||||||
|
case FieldURL:
|
||||||
|
return "URL"
|
||||||
|
case FieldPath:
|
||||||
|
return "Path"
|
||||||
|
case FieldTags:
|
||||||
|
return "Tags"
|
||||||
|
case FieldPasswordProfile:
|
||||||
|
return "Password Profile"
|
||||||
|
case FieldNotes:
|
||||||
|
return "Notes"
|
||||||
|
case FieldFields:
|
||||||
|
return "Custom Fields"
|
||||||
|
case FieldHistoryIndex:
|
||||||
|
return "History Index"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(string(field), "-", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FocusOrder() []Field {
|
||||||
|
return []Field{
|
||||||
|
FieldID,
|
||||||
|
FieldTitle,
|
||||||
|
FieldUsername,
|
||||||
|
FieldPassword,
|
||||||
|
FieldURL,
|
||||||
|
FieldPath,
|
||||||
|
FieldTags,
|
||||||
|
FieldPasswordProfile,
|
||||||
|
FieldNotes,
|
||||||
|
FieldFields,
|
||||||
|
FieldHistoryIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
|
return u.headerActions(gtx)
|
||||||
|
}
|
||||||
|
if u.shouldShowDesktopWorkingHeader() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.brandMark(gtx, 196, 56)
|
||||||
|
}),
|
||||||
|
layout.Rigid(u.headerActions),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
spacing := gtx.Dp(unit.Dp(8))
|
||||||
|
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 {
|
||||||
|
metrics.SyncDims = u.syncButtonGroup(gtx)
|
||||||
|
return metrics.SyncDims
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||||
|
metrics.LockDims = btn.Layout(gtx)
|
||||||
|
return metrics.LockDims
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||||
|
return metrics.MainDims
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowOps := op.Record(gtx.Ops)
|
||||||
|
metrics.RowDims = row(gtx)
|
||||||
|
rowCall := rowOps.Stop()
|
||||||
|
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
||||||
|
}
|
||||||
|
|
||||||
|
surface := 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)
|
||||||
|
rowStack.Pop()
|
||||||
|
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
if u.syncMenuOpen {
|
||||||
|
u.phoneSyncMenuVisible = true
|
||||||
|
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
|
||||||
|
}
|
||||||
|
if u.mainMenuOpen {
|
||||||
|
u.phoneMainMenuVisible = true
|
||||||
|
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, metrics.RowDims.Size.Y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.syncMenuOpen {
|
||||||
|
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
|
||||||
|
}
|
||||||
|
if u.mainMenuOpen {
|
||||||
|
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout.Dimensions{Size: image.Pt(metrics.RowDims.Size.X, metrics.RowDims.Size.Y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) topRightActionOrder() []string {
|
||||||
|
if u.isVaultLocked() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"Sync", "Lock", "Menu"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min = gtx.Constraints.Max
|
||||||
|
contentInsetPx := gtx.Dp(unit.Dp(16))
|
||||||
|
surface := headerlayout.DropdownSurface{
|
||||||
|
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
||||||
|
LeftInset: contentInsetPx,
|
||||||
|
TopInset: contentInsetPx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.syncMenuVisibleOnPhone() {
|
||||||
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
||||||
|
}
|
||||||
|
if u.mainMenuVisibleOnPhone() {
|
||||||
|
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
||||||
|
}
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) syncMenuVisibleOnPhone() bool {
|
||||||
|
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) mainMenuVisibleOnPhone() bool {
|
||||||
|
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
|
||||||
|
|
||||||
|
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
|
||||||
|
|
||||||
|
func (u *ui) headerMenusUseOverlayModel() bool { return true }
|
||||||
|
|
||||||
|
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
|
||||||
|
|
||||||
|
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
|
||||||
|
|
||||||
|
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !u.usesCompactViewport() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
||||||
|
}
|
||||||
|
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
||||||
|
width := gtx.Dp(unit.Dp(widthDP))
|
||||||
|
height := gtx.Dp(unit.Dp(heightDP))
|
||||||
|
if width > gtx.Constraints.Max.X {
|
||||||
|
width = gtx.Constraints.Max.X
|
||||||
|
}
|
||||||
|
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
||||||
|
height = gtx.Constraints.Max.Y
|
||||||
|
}
|
||||||
|
img := widget.Image{
|
||||||
|
Src: src,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.W,
|
||||||
|
Scale: 1.0 / gtx.Metric.PxPerDp,
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min = image.Point{}
|
||||||
|
gtx.Constraints.Max = image.Pt(width, height)
|
||||||
|
return img.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
||||||
|
rows := []layout.Widget{
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rowWidth := menuActionWidth(gtx, rows)
|
||||||
|
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||||
|
icon := u.menuIcon
|
||||||
|
if icon == nil {
|
||||||
|
icon = u.settingsIcon
|
||||||
|
}
|
||||||
|
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
||||||
|
if u.mainMenuOpen {
|
||||||
|
btn.Background = accentColor
|
||||||
|
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||||
|
} else {
|
||||||
|
btn.Background = selectedColor
|
||||||
|
btn.Color = accentColor
|
||||||
|
}
|
||||||
|
btn.Size = unit.Dp(18)
|
||||||
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||||
|
return btn.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||||
|
measureGTX := gtx
|
||||||
|
measureGTX.Constraints.Min = image.Point{}
|
||||||
|
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
||||||
|
macro := op.Record(gtx.Ops)
|
||||||
|
contentDims := w(measureGTX)
|
||||||
|
_ = macro.Stop()
|
||||||
|
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
||||||
|
maxWidth := gtx.Constraints.Max.X
|
||||||
|
if maxWidth > 0 && width > maxWidth {
|
||||||
|
width = maxWidth
|
||||||
|
}
|
||||||
|
if width > 0 {
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
}
|
||||||
|
return compactCard(gtx, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||||
|
width := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
measureGTX := gtx
|
||||||
|
measureGTX.Constraints.Min = image.Point{}
|
||||||
|
macro := op.Record(gtx.Ops)
|
||||||
|
dims := row(measureGTX)
|
||||||
|
_ = macro.Stop()
|
||||||
|
if dims.Size.X > width {
|
||||||
|
width = dims.Size.X
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||||
|
if width <= 0 {
|
||||||
|
return child(gtx)
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return layout.E.Layout(gtx, child)
|
||||||
|
}
|
||||||
@@ -1,177 +1,33 @@
|
|||||||
package appui
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"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/appstate"
|
||||||
appuilayout "git.julianfamily.org/keepassgo/internal/appui/layout"
|
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
||||||
return u.headerActions(gtx)
|
|
||||||
}
|
|
||||||
if u.shouldShowDesktopWorkingHeader() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return u.brandMark(gtx, 196, 56)
|
|
||||||
}),
|
|
||||||
layout.Rigid(u.headerActions),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
|
||||||
if u.shouldShowLifecycleSetup() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
if u.isVaultLocked() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
if u.shouldShowDesktopWorkingHeader() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
|
||||||
metrics := appuilayout.HeaderActionMetrics{Spacing: spacing}
|
|
||||||
row := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
metrics.SyncDims = u.syncButtonGroup(gtx)
|
|
||||||
return metrics.SyncDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
metrics.LockDims = btn.Layout(gtx)
|
|
||||||
return metrics.LockDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
|
||||||
return metrics.MainDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowOps := op.Record(gtx.Ops)
|
|
||||||
metrics.RowDims = row(gtx)
|
|
||||||
rowCall := rowOps.Stop()
|
|
||||||
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
|
||||||
}
|
|
||||||
|
|
||||||
surface := appuilayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
|
||||||
|
|
||||||
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
|
|
||||||
rowCall.Add(gtx.Ops)
|
|
||||||
rowStack.Pop()
|
|
||||||
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
if u.syncMenuOpen {
|
|
||||||
u.phoneSyncMenuVisible = true
|
|
||||||
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
|
|
||||||
}
|
|
||||||
if u.mainMenuOpen {
|
|
||||||
u.phoneMainMenuVisible = true
|
|
||||||
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
|
|
||||||
}
|
|
||||||
width := gtx.Constraints.Max.X
|
|
||||||
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.syncMenuOpen {
|
|
||||||
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
|
|
||||||
}
|
|
||||||
if u.mainMenuOpen {
|
|
||||||
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
|
|
||||||
}
|
|
||||||
|
|
||||||
width := metrics.RowDims.Size.X
|
|
||||||
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
rowWidth := menuActionWidth(gtx, rows)
|
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[0])
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[1])
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[2])
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[3])
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[4])
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return rightAlignedMenuAction(gtx, rowWidth, rows[5])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||||
label := "Sync"
|
|
||||||
spacing := unit.Dp(4)
|
spacing := unit.Dp(4)
|
||||||
if u.usesCompactViewport() {
|
if u.usesCompactViewport() {
|
||||||
spacing = unit.Dp(3)
|
spacing = unit.Dp(3)
|
||||||
}
|
}
|
||||||
row := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport())
|
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.syncMenuToggle(gtx)
|
return u.syncMenuToggle(gtx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return row(gtx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -205,12 +61,11 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|||||||
actionRows := u.syncMenuActionRows(model)
|
actionRows := u.syncMenuActionRows(model)
|
||||||
actionWidth := menuActionWidth(gtx, actionRows)
|
actionWidth := menuActionWidth(gtx, actionRows)
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
rows := u.syncMenuRows(model, profiles, credentials, actionWidth)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget {
|
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
||||||
rows := []layout.Widget{
|
rows := []layout.Widget{
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
||||||
@@ -244,7 +99,7 @@ func (u *ui) syncMenuActionRows(model actions.SyncMenuModel) []layout.Widget {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
|
||||||
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
rows := u.syncMenuPrimaryRows(model, actionWidth)
|
||||||
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
|
||||||
if model.ShowSaveCurrentBinding {
|
if model.ShowSaveCurrentBinding {
|
||||||
@@ -253,7 +108,7 @@ func (u *ui) syncMenuRows(model actions.SyncMenuModel, profiles []vault.RemotePr
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuPrimaryRows(model actions.SyncMenuModel, actionWidth int) []layout.FlexChild {
|
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
||||||
rows := []layout.FlexChild{
|
rows := []layout.FlexChild{
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
|
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
|
||||||
@@ -302,7 +157,7 @@ func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||||
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -333,7 +188,7 @@ func (u *ui) syncMenuSavedBindingRows(model actions.SyncMenuModel, profiles []va
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Widget {
|
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
summary := model.SavedBindingSummary
|
summary := model.SavedBindingSummary
|
||||||
if !summary.OK {
|
if !summary.OK {
|
||||||
@@ -365,7 +220,7 @@ func (u *ui) syncMenuSavedBindingSummary(model actions.SyncMenuModel) layout.Wid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexChild {
|
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
|
||||||
return []layout.FlexChild{
|
return []layout.FlexChild{
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -380,7 +235,7 @@ func (u *ui) syncMenuSaveBindingRows(model actions.SyncMenuModel) []layout.FlexC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
|
||||||
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
|
||||||
for i, profile := range profiles {
|
for i, profile := range profiles {
|
||||||
i := i
|
i := i
|
||||||
@@ -422,125 +277,44 @@ func (u *ui) syncMenuSelectorRows(_ actions.SyncMenuModel, profiles []vault.Remo
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
|
||||||
measureGTX := gtx
|
model := syncmodel.MenuModel{
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
HasOpenVault: u.hasOpenVault(),
|
||||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
||||||
macro := op.Record(gtx.Ops)
|
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
||||||
contentDims := w(measureGTX)
|
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
_ = macro.Stop()
|
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
||||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
maxWidth := gtx.Constraints.Max.X
|
RemotePassword: u.remotePassword.Text(),
|
||||||
if maxWidth > 0 && width > maxWidth {
|
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
||||||
width = maxWidth
|
|
||||||
}
|
}
|
||||||
if width > 0 {
|
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
||||||
gtx.Constraints.Min.X = width
|
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
||||||
gtx.Constraints.Max.X = width
|
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
||||||
}
|
return model
|
||||||
return compactCard(gtx, w)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) topRightActionOrder() []string {
|
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
|
||||||
if u.isVaultLocked() {
|
profile, ok := u.selectedVaultRemoteProfile()
|
||||||
return nil
|
if !ok {
|
||||||
|
return syncmodel.MenuBindingSummary{}
|
||||||
}
|
}
|
||||||
return []string{"Sync", "Lock", "Menu"}
|
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
||||||
}
|
if !ok {
|
||||||
|
return syncmodel.MenuBindingSummary{}
|
||||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
|
||||||
button := func(gtx layout.Context) layout.Dimensions {
|
|
||||||
icon := u.menuIcon
|
|
||||||
if icon == nil {
|
|
||||||
icon = u.settingsIcon
|
|
||||||
}
|
}
|
||||||
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
credentialLabel := entry.Title
|
||||||
if u.mainMenuOpen {
|
if strings.TrimSpace(entry.Username) != "" {
|
||||||
btn.Background = accentColor
|
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = selectedColor
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
}
|
||||||
btn.Size = unit.Dp(18)
|
syncLabel := "Sync manually when you choose Use Remote Sync."
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
||||||
return btn.Layout(gtx)
|
syncLabel = "Syncs automatically on open and save."
|
||||||
}
|
}
|
||||||
return button(gtx)
|
return syncmodel.MenuBindingSummary{
|
||||||
}
|
ProfileLabel: profile.Name,
|
||||||
|
CredentialLabel: credentialLabel,
|
||||||
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
SyncLabel: syncLabel,
|
||||||
if !u.usesCompactViewport() {
|
OK: true,
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
}
|
||||||
if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min = gtx.Constraints.Max
|
|
||||||
contentInsetPx := gtx.Dp(unit.Dp(16))
|
|
||||||
surface := appuilayout.DropdownSurface{
|
|
||||||
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
|
||||||
LeftInset: contentInsetPx,
|
|
||||||
TopInset: contentInsetPx,
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.syncMenuVisibleOnPhone() {
|
|
||||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
|
||||||
}
|
|
||||||
if u.mainMenuVisibleOnPhone() {
|
|
||||||
surface.Draw(gtx, appuilayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
|
||||||
}
|
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuVisibleOnPhone() bool {
|
|
||||||
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuDropsBelowTrigger() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) syncMenuRightAlignsToTrigger() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) headerMenusUseOverlayModel() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuDropsBelowTrigger() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuRightAlignsToTrigger() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|
||||||
width := 0
|
|
||||||
for _, row := range rows {
|
|
||||||
measureGTX := gtx
|
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
|
||||||
macro := op.Record(gtx.Ops)
|
|
||||||
dims := row(measureGTX)
|
|
||||||
_ = macro.Stop()
|
|
||||||
if dims.Size.X > width {
|
|
||||||
width = dims.Size.X
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
|
||||||
if width <= 0 {
|
|
||||||
return child(gtx)
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min.X = width
|
|
||||||
gtx.Constraints.Max.X = width
|
|
||||||
return layout.E.Layout(gtx, child)
|
|
||||||
}
|
}
|
||||||
@@ -5,28 +5,42 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/layout"
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
type focusID string
|
type focusID string
|
||||||
|
|
||||||
type detailField string
|
type detailField = editormodel.Field
|
||||||
|
|
||||||
const (
|
const (
|
||||||
focusSearch focusID = "search"
|
focusSearch focusID = "search"
|
||||||
|
|
||||||
detailFieldID detailField = "id"
|
detailFieldID = editormodel.FieldID
|
||||||
detailFieldTitle detailField = "title"
|
detailFieldTitle = editormodel.FieldTitle
|
||||||
detailFieldUsername detailField = "username"
|
detailFieldUsername = editormodel.FieldUsername
|
||||||
detailFieldPassword detailField = "password"
|
detailFieldPassword = editormodel.FieldPassword
|
||||||
detailFieldURL detailField = "url"
|
detailFieldURL = editormodel.FieldURL
|
||||||
detailFieldPath detailField = "path"
|
detailFieldPath = editormodel.FieldPath
|
||||||
detailFieldTags detailField = "tags"
|
detailFieldTags = editormodel.FieldTags
|
||||||
detailFieldPasswordProfile detailField = "password-profile"
|
detailFieldPasswordProfile = editormodel.FieldPasswordProfile
|
||||||
detailFieldNotes detailField = "notes"
|
detailFieldNotes = editormodel.FieldNotes
|
||||||
detailFieldFields detailField = "fields"
|
detailFieldFields = editormodel.FieldFields
|
||||||
detailFieldHistoryIndex detailField = "history-index"
|
detailFieldHistoryIndex = editormodel.FieldHistoryIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
shortcutSearch = "search"
|
||||||
|
shortcutSave = "save"
|
||||||
|
shortcutLock = "lock"
|
||||||
|
shortcutNewEntry = "new-entry"
|
||||||
|
shortcutCopyUser = "copy-user"
|
||||||
|
shortcutCopyPassword = "copy-password"
|
||||||
|
shortcutCopyURL = "copy-url"
|
||||||
)
|
)
|
||||||
|
|
||||||
func breadcrumbFocusID(index int) focusID {
|
func breadcrumbFocusID(index int) focusID {
|
||||||
@@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID {
|
|||||||
return focusID("detail:" + string(field))
|
return focusID("detail:" + string(field))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) processShortcuts(gtx layout.Context) {
|
||||||
|
event.Op(gtx.Ops, u)
|
||||||
|
for {
|
||||||
|
ev, ok := gtx.Event(
|
||||||
|
key.Filter{Name: "F", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "S", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "L", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "N", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "U", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "P", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: "O", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
||||||
|
key.Filter{Name: key.NameLeftArrow},
|
||||||
|
key.Filter{Name: key.NameRightArrow},
|
||||||
|
key.Filter{Name: key.NameUpArrow},
|
||||||
|
key.Filter{Name: key.NameDownArrow},
|
||||||
|
key.Filter{Name: key.NameReturn},
|
||||||
|
key.Filter{Name: key.NameBack},
|
||||||
|
key.Filter{Name: key.NameEscape},
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
ke, ok := ev.(key.Event)
|
||||||
|
if !ok || ke.State != key.Press {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||||
|
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
||||||
|
_ = u.handlePhoneBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) performShortcut(name string) error {
|
||||||
|
switch name {
|
||||||
|
case shortcutSearch:
|
||||||
|
u.keyboardFocus = focusSearch
|
||||||
|
return nil
|
||||||
|
case shortcutSave:
|
||||||
|
return u.saveAction()
|
||||||
|
case shortcutLock:
|
||||||
|
return u.lockAction()
|
||||||
|
case shortcutNewEntry:
|
||||||
|
u.state.BeginNewEntry()
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||||
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||||
|
return nil
|
||||||
|
case shortcutCopyUser:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
||||||
|
case shortcutCopyPassword:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
||||||
|
case shortcutCopyURL:
|
||||||
|
return u.copySelectedFieldAction(clipboard.TargetURL)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
||||||
if u.handleShortcutKey(name, modifiers) {
|
if u.handleShortcutKey(name, modifiers) {
|
||||||
return true
|
return true
|
||||||
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func detailFocusOrder() []detailField {
|
func detailFocusOrder() []detailField {
|
||||||
return []detailField{
|
return editormodel.FocusOrder()
|
||||||
detailFieldID,
|
|
||||||
detailFieldTitle,
|
|
||||||
detailFieldUsername,
|
|
||||||
detailFieldPassword,
|
|
||||||
detailFieldURL,
|
|
||||||
detailFieldPath,
|
|
||||||
detailFieldTags,
|
|
||||||
detailFieldPasswordProfile,
|
|
||||||
detailFieldNotes,
|
|
||||||
detailFieldFields,
|
|
||||||
detailFieldHistoryIndex,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalFocusID(id focusID) focusID {
|
func canonicalFocusID(id focusID) focusID {
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
type OpenIntent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpenIntentNone OpenIntent = ""
|
||||||
|
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
|
||||||
|
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
|
||||||
|
)
|
||||||
@@ -0,0 +1,778 @@
|
|||||||
|
package appui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/x/explorer"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) createVaultAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
|
||||||
|
Cipher: strings.TrimSpace(u.securityCipher.Text()),
|
||||||
|
KDF: strings.TrimSpace(u.securityKDF.Text()),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.CreateVault(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.lifecycleMode == "local" {
|
||||||
|
u.selectedVaultRemoteProfileID = ""
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = ""
|
||||||
|
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
|
||||||
|
u.remoteBaseURL.SetText("")
|
||||||
|
u.remotePath.SetText("")
|
||||||
|
u.remoteUsername.SetText("")
|
||||||
|
u.remotePassword.SetText("")
|
||||||
|
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.vaultPath.SetText(u.saveAsTargetPath())
|
||||||
|
u.noteRecentVault(u.saveAsTargetPath())
|
||||||
|
}
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openVaultAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.OpenVault(path, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.restoreRecentVaultGroup(path)
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||||
|
}
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
u.applyPendingLifecycleOpenIntent()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startOpenVaultAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("open vault", u.openVaultAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.lastLifecycleAction = "open vault"
|
||||||
|
u.runBackgroundAction("open vault", func() (func() error, error) {
|
||||||
|
prepared, err := session.PrepareLocalOpen(path, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedLocalOpen(prepared)
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.restoreRecentVaultGroup(path)
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
|
||||||
|
u.showStatusMessage("Remote sync on open failed: " + err.Error())
|
||||||
|
}
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
u.applyPendingLifecycleOpenIntent()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text()) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lifecycleRemoteSyncActionLabel() string {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return "Open Vault And Set Up Remote Sync"
|
||||||
|
}
|
||||||
|
if hasBoundRecentRemote(u.recentRemotes, path) {
|
||||||
|
return "Open Vault And Open Remote Sync Settings"
|
||||||
|
}
|
||||||
|
return "Open Vault And Set Up Remote Sync"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) beginLifecycleRemoteSyncOpen() {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
switch {
|
||||||
|
case path == "":
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||||
|
case hasBoundRecentRemote(u.recentRemotes, path):
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
|
||||||
|
default:
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
|
||||||
|
}
|
||||||
|
u.startOpenVaultAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) applyPendingLifecycleOpenIntent() {
|
||||||
|
intent := u.pendingLifecycleOpenIntent
|
||||||
|
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
|
||||||
|
switch intent {
|
||||||
|
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
|
||||||
|
u.openRemoteSyncSetupDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAction() error {
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAsAction() error {
|
||||||
|
path := u.saveAsTargetPath()
|
||||||
|
if err := u.state.SaveAs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.vaultPath.SetText(path)
|
||||||
|
u.noteRecentVault(path)
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openRemoteAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
|
||||||
|
return err
|
||||||
|
} else if ok {
|
||||||
|
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
|
Password: u.remotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(
|
||||||
|
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
strings.TrimSpace(u.remotePath.Text()),
|
||||||
|
)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startOpenRemoteAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("open remote vault", u.openRemoteAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
||||||
|
Password: u.remotePassword.Text(),
|
||||||
|
}
|
||||||
|
remotePath := strings.TrimSpace(u.remotePath.Text())
|
||||||
|
u.lastLifecycleAction = "open remote vault"
|
||||||
|
u.runBackgroundAction("open remote vault", func() (func() error, error) {
|
||||||
|
binding, bindingOK := u.selectedVaultRemoteBinding()
|
||||||
|
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
|
||||||
|
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved, err := binding.Resolve(preparedLocal.Model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}, resolved.Profile.Path, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedLocalOpen(preparedLocal)
|
||||||
|
u.vaultPath.SetText(binding.LocalVaultPath)
|
||||||
|
u.noteRecentVault(binding.LocalVaultPath)
|
||||||
|
u.restoreRecentVaultGroup(binding.LocalVaultPath)
|
||||||
|
manager.ApplyPreparedRemoteOpen(preparedRemote)
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if u.hasOpenVault() {
|
||||||
|
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
client = webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
remotePath = resolved.Profile.Path
|
||||||
|
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
|
||||||
|
u.remotePath.SetText(resolved.Profile.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedRemoteOpen(prepared)
|
||||||
|
if err := u.materializeCurrentRemoteCache(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.noteRecentRemote(
|
||||||
|
strings.TrimSpace(u.remoteBaseURL.Text()),
|
||||||
|
remotePath,
|
||||||
|
)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) lockAction() error {
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err := u.state.Lock(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) unlockAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.Unlock(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startUnlockAction() {
|
||||||
|
manager, ok := u.state.Session.(*session.Manager)
|
||||||
|
if !ok {
|
||||||
|
u.runAction("unlock vault", u.unlockAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded := append([]byte(nil), manager.EncodedBytes()...)
|
||||||
|
u.runBackgroundAction("unlock vault", func() (func() error, error) {
|
||||||
|
prepared, err := session.PrepareUnlock(encoded, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
manager.ApplyPreparedUnlock(prepared)
|
||||||
|
u.resetPasswordPeek()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.loadSecuritySettingsFromSession()
|
||||||
|
u.editingEntry = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) changeMasterKeyAction() error {
|
||||||
|
key, err := u.currentMasterKey()
|
||||||
|
defer u.clearMasterPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.state.ChangeMasterKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) loadSecuritySettingsFromSession() {
|
||||||
|
settings, err := u.state.SecuritySettings()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.securityCipher.SetText(settings.Cipher)
|
||||||
|
u.securityKDF.SetText(settings.KDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearMasterPassword() {
|
||||||
|
u.masterPassword.SetText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) synchronizeAction() error {
|
||||||
|
if err := u.state.Synchronize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openAdvancedSyncDialog() {
|
||||||
|
u.syncDialogOpen = true
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
|
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||||
|
u.syncSourceMode = u.syncDefaultSourceMode
|
||||||
|
u.syncDirection = u.syncDefaultDirection
|
||||||
|
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||||
|
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||||
|
}
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) openRemoteSyncSetupDialog() {
|
||||||
|
u.syncDialogOpen = true
|
||||||
|
u.syncMenuOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
|
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||||
|
u.syncSourceMode = syncSourceRemote
|
||||||
|
u.syncDirection = syncDirectionPush
|
||||||
|
u.syncSetupAutomatic.Value = true
|
||||||
|
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
|
||||||
|
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
|
||||||
|
}
|
||||||
|
u.syncSavedRemoteBindingSelection()
|
||||||
|
u.prefillAdvancedSyncRemoteFromSavedBinding()
|
||||||
|
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
|
||||||
|
u.syncSetupAutomatic.Value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) clearSyncLocalImport() {
|
||||||
|
u.syncLocalImportName = ""
|
||||||
|
u.syncLocalImportContent = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
|
||||||
|
name := strings.TrimSpace(u.syncLocalImportName)
|
||||||
|
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return name, append([]byte(nil), u.syncLocalImportContent...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
|
||||||
|
switch mode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
return syncSourceRemote
|
||||||
|
default:
|
||||||
|
return syncSourceLocal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSyncDirection(direction syncDirection) syncDirection {
|
||||||
|
switch direction {
|
||||||
|
case syncDirectionPush:
|
||||||
|
return syncDirectionPush
|
||||||
|
default:
|
||||||
|
return syncDirectionPull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncAction() error {
|
||||||
|
switch u.syncDirection {
|
||||||
|
case syncDirectionPush:
|
||||||
|
return u.advancedSyncToAction()
|
||||||
|
default:
|
||||||
|
return u.advancedSyncFromAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncFromAction() error {
|
||||||
|
switch u.syncSourceMode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if name, content, ok := u.selectedSyncLocalImport(); ok {
|
||||||
|
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeFromLocal(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.syncDialogOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startChooseSyncLocalSourceAction() {
|
||||||
|
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||||
|
u.runAction("choose sync path", func() error {
|
||||||
|
u.clearSyncLocalImport()
|
||||||
|
return u.chooseExistingFileAction(&u.syncLocalPath)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("choose sync file", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
label := "Selected Android vault"
|
||||||
|
return func() error {
|
||||||
|
u.syncLocalImportName = label
|
||||||
|
u.syncLocalImportContent = append([]byte(nil), content...)
|
||||||
|
u.syncLocalPath.SetText(label)
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickedDocumentName(file io.ReadCloser, fallback string) string {
|
||||||
|
if named, ok := file.(interface{ Name() string }); ok {
|
||||||
|
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback = filepath.Base(strings.TrimSpace(fallback))
|
||||||
|
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
|
||||||
|
return "selected-vault.kdbx"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startChooseVaultPathAction() {
|
||||||
|
if runtime.GOOS != "android" || u.fileExplorer == nil {
|
||||||
|
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("choose vault file", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := pickedDocumentName(file, "selected-vault.kdbx")
|
||||||
|
return func() error {
|
||||||
|
return u.importSharedVaultBytesAction(name, content)
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) startImportSharedVaultAction() {
|
||||||
|
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.runBackgroundAction("import shared vault", func() (func() error, error) {
|
||||||
|
file, err := u.fileExplorer.ChooseFile(".kdbx")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, explorer.ErrUserDecline) {
|
||||||
|
return func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() error {
|
||||||
|
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) advancedSyncToAction() error {
|
||||||
|
switch u.syncSourceMode {
|
||||||
|
case syncSourceRemote:
|
||||||
|
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
|
||||||
|
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
|
||||||
|
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.showStatusMessage("Remote sync is set up for this vault.")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
path := strings.TrimSpace(u.syncLocalPath.Text())
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.SynchronizeToLocal(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.syncDialogOpen = false
|
||||||
|
u.showSyncPassword = false
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
remotePath = strings.TrimSpace(remotePath)
|
||||||
|
if baseURL == "" || remotePath == "" {
|
||||||
|
return fmt.Errorf("remote setup requires base URL and path")
|
||||||
|
}
|
||||||
|
input := appstate.RemoteBindingInput{
|
||||||
|
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
|
||||||
|
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||||
|
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
|
||||||
|
BaseURL: baseURL,
|
||||||
|
RemotePath: remotePath,
|
||||||
|
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
|
||||||
|
CredentialTitle: "WebDAV Sign-In" + func() string {
|
||||||
|
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
|
||||||
|
return " · " + user
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
|
||||||
|
Password: u.syncRemotePassword.Text(),
|
||||||
|
CredentialPath: append([]string(nil), u.currentPath...),
|
||||||
|
SyncMode: u.syncSetupMode(),
|
||||||
|
}
|
||||||
|
binding, err := u.state.ConfigureRemoteBinding(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
|
||||||
|
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
|
||||||
|
u.selectedVaultRemoteSyncMode = binding.SyncMode
|
||||||
|
u.remoteBaseURL.SetText(baseURL)
|
||||||
|
u.remotePath.SetText(remotePath)
|
||||||
|
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
|
||||||
|
u.remotePassword.SetText(u.syncRemotePassword.Text())
|
||||||
|
u.noteRecentRemote(baseURL, remotePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAsTargetPath() string {
|
||||||
|
path := strings.TrimSpace(u.saveAsPath.Text())
|
||||||
|
if path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return u.defaultSaveAsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) importedVaultDestination(name string) string {
|
||||||
|
baseTarget := u.saveAsTargetPath()
|
||||||
|
baseDir := filepath.Dir(baseTarget)
|
||||||
|
baseName := filepath.Base(strings.TrimSpace(name))
|
||||||
|
switch {
|
||||||
|
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
|
||||||
|
return baseTarget
|
||||||
|
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
|
||||||
|
return filepath.Join(baseDir, baseName)
|
||||||
|
default:
|
||||||
|
return baseTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) consumePendingSharedVaultImport() {
|
||||||
|
path := strings.TrimSpace(u.pendingSharedVaultPath)
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := "shared-vault.kdbx"
|
||||||
|
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||||
|
if rawName, err := os.ReadFile(namePath); err == nil {
|
||||||
|
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
|
||||||
|
name = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := u.importSharedVaultBytesAction(name, content); err != nil {
|
||||||
|
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
|
||||||
|
_ = os.Remove(namePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
|
||||||
|
target := u.importedVaultDestination(name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.lifecycleMode = "local"
|
||||||
|
u.vaultPath.SetText(target)
|
||||||
|
u.noteRecentVault(target)
|
||||||
|
u.state.ErrorMessage = ""
|
||||||
|
u.state.StatusMessage = ""
|
||||||
|
u.requestMasterPassFocus = true
|
||||||
|
u.filter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) currentShareableVaultPath() string {
|
||||||
|
return strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) shareCurrentVaultAction() error {
|
||||||
|
if u.vaultSharer == nil {
|
||||||
|
return fmt.Errorf("vault sharing is not available on this platform")
|
||||||
|
}
|
||||||
|
path := u.currentShareableVaultPath()
|
||||||
|
if path == "" {
|
||||||
|
return errors.New(errVaultPathRequired)
|
||||||
|
}
|
||||||
|
if err := u.state.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
|
||||||
|
}
|
||||||
@@ -18,6 +18,23 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||||
|
panel := card
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
panel = compactCard
|
||||||
|
}
|
||||||
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
rows := []layout.Widget{
|
||||||
|
u.lifecycleBranding,
|
||||||
|
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||||
|
u.lifecycleControls,
|
||||||
|
}
|
||||||
|
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
return rows[i](gtx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
showLocalChooser := u.showLocalVaultChooser()
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
type TopSection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopSearch TopSection = "search"
|
||||||
|
TopNavigation TopSection = "navigation"
|
||||||
|
TopPath TopSection = "path"
|
||||||
|
TopGroup TopSection = "group"
|
||||||
|
TopGroupTools TopSection = "group_tools"
|
||||||
|
TopPrimary TopSection = "primary"
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
type EntriesSectionState struct {
|
||||||
|
Path []string
|
||||||
|
SearchQuery string
|
||||||
|
SelectedEntryID string
|
||||||
|
Editing bool
|
||||||
|
}
|
||||||
+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/header/layout"
|
||||||
|
listlayout "git.julianfamily.org/keepassgo/internal/appui/list/layout"
|
||||||
"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)
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package appui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,29 +11,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||||
|
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
displayDensityDense = "dense"
|
displayDensityDense = settingsmodel.DisplayDensityDense
|
||||||
displayDensityComfortable = "comfortable"
|
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
|
||||||
|
|
||||||
contrastStandard = "standard"
|
contrastStandard = settingsmodel.ContrastStandard
|
||||||
contrastHigh = "high"
|
contrastHigh = settingsmodel.ContrastHigh
|
||||||
|
|
||||||
keyboardFocusStandard = "standard"
|
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
|
||||||
keyboardFocusProminent = "prominent"
|
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
|
||||||
)
|
)
|
||||||
|
|
||||||
type accessibilityPreferences struct {
|
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||||
DisplayDensity string
|
|
||||||
Contrast string
|
|
||||||
ReducedMotion bool
|
|
||||||
KeyboardFocus string
|
|
||||||
}
|
|
||||||
|
|
||||||
type settingsFile struct {
|
type settingsFile struct {
|
||||||
Sync syncSettings `json:"sync,omitempty"`
|
Sync syncSettings `json:"sync,omitempty"`
|
||||||
@@ -63,37 +64,23 @@ type choiceSpec struct {
|
|||||||
Active bool
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type focusAppearance struct {
|
||||||
|
BorderColor color.NRGBA
|
||||||
|
OutlineColor color.NRGBA
|
||||||
|
OutlineWidth int
|
||||||
|
MinHeight int
|
||||||
|
}
|
||||||
|
|
||||||
func defaultAccessibilityPreferences() accessibilityPreferences {
|
func defaultAccessibilityPreferences() accessibilityPreferences {
|
||||||
return accessibilityPreferences{
|
return settingsmodel.DefaultAccessibilityPreferences()
|
||||||
DisplayDensity: displayDensityForDenseLayout(true),
|
|
||||||
Contrast: contrastStandard,
|
|
||||||
KeyboardFocus: keyboardFocusStandard,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayDensityForDenseLayout(dense bool) string {
|
func displayDensityForDenseLayout(dense bool) string {
|
||||||
if dense {
|
return settingsmodel.DisplayDensityForDenseLayout(dense)
|
||||||
return displayDensityDense
|
|
||||||
}
|
|
||||||
return displayDensityComfortable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
|
||||||
normalized := defaultAccessibilityPreferences()
|
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
|
||||||
switch prefs.DisplayDensity {
|
|
||||||
case displayDensityDense, displayDensityComfortable:
|
|
||||||
normalized.DisplayDensity = prefs.DisplayDensity
|
|
||||||
}
|
|
||||||
switch prefs.Contrast {
|
|
||||||
case contrastStandard, contrastHigh:
|
|
||||||
normalized.Contrast = prefs.Contrast
|
|
||||||
}
|
|
||||||
switch prefs.KeyboardFocus {
|
|
||||||
case keyboardFocusStandard, keyboardFocusProminent:
|
|
||||||
normalized.KeyboardFocus = prefs.KeyboardFocus
|
|
||||||
}
|
|
||||||
normalized.ReducedMotion = prefs.ReducedMotion
|
|
||||||
return normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
||||||
@@ -102,6 +89,96 @@ func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
|
|||||||
u.accessibilityPrefs = normalized
|
u.accessibilityPrefs = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
|
appearance := focusAppearance{
|
||||||
|
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||||
|
OutlineColor: color.NRGBA{A: 0},
|
||||||
|
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||||
|
MinHeight: metric.Dp(unit.Dp(44)),
|
||||||
|
}
|
||||||
|
if prefs.DisplayDensity == displayDensityComfortable {
|
||||||
|
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
||||||
|
}
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
appearance.BorderColor = accentColor
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||||
|
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
||||||
|
}
|
||||||
|
if prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||||
|
prefs = normalizeAccessibilityPreferences(prefs)
|
||||||
|
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
|
text = accentColor
|
||||||
|
if prefs.Contrast == contrastHigh {
|
||||||
|
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
||||||
|
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||||
|
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
||||||
|
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return background, text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) accessibilityLabel(id focusID) string {
|
||||||
|
switch {
|
||||||
|
case id == focusSearch:
|
||||||
|
return "Search vault"
|
||||||
|
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
crumbs := u.breadcrumbLabels()
|
||||||
|
if index >= 0 && index < len(crumbs) {
|
||||||
|
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "list:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
if index >= 0 && index < len(u.visible) {
|
||||||
|
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "detail:"):
|
||||||
|
name := strings.TrimPrefix(string(id), "detail:")
|
||||||
|
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||||
|
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
width := appearance.OutlineWidth
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) isFocused(id focusID) bool {
|
||||||
|
return u.keyboardFocus == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailFieldLabel(field detailField) string {
|
||||||
|
return editormodel.Label(field)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) loadSettingsDraft() {
|
func (u *ui) loadSettingsDraft() {
|
||||||
u.settingsDraft = settingsDraft{
|
u.settingsDraft = settingsDraft{
|
||||||
Accessibility: accessibilityPreferences{
|
Accessibility: accessibilityPreferences{
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
type AccessibilityPreferences struct {
|
||||||
|
DisplayDensity string
|
||||||
|
Contrast string
|
||||||
|
ReducedMotion bool
|
||||||
|
KeyboardFocus string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DisplayDensityDense = "dense"
|
||||||
|
DisplayDensityComfortable = "comfortable"
|
||||||
|
ContrastStandard = "standard"
|
||||||
|
ContrastHigh = "high"
|
||||||
|
KeyboardFocusStandard = "standard"
|
||||||
|
KeyboardFocusProminent = "prominent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultAccessibilityPreferences() AccessibilityPreferences {
|
||||||
|
return AccessibilityPreferences{
|
||||||
|
DisplayDensity: DisplayDensityDense,
|
||||||
|
Contrast: ContrastStandard,
|
||||||
|
KeyboardFocus: KeyboardFocusStandard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisplayDensityForDenseLayout(dense bool) string {
|
||||||
|
if dense {
|
||||||
|
return DisplayDensityDense
|
||||||
|
}
|
||||||
|
return DisplayDensityComfortable
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
|
||||||
|
normalized := DefaultAccessibilityPreferences()
|
||||||
|
switch prefs.DisplayDensity {
|
||||||
|
case DisplayDensityDense, DisplayDensityComfortable:
|
||||||
|
normalized.DisplayDensity = prefs.DisplayDensity
|
||||||
|
}
|
||||||
|
switch prefs.Contrast {
|
||||||
|
case ContrastStandard, ContrastHigh:
|
||||||
|
normalized.Contrast = prefs.Contrast
|
||||||
|
}
|
||||||
|
switch prefs.KeyboardFocus {
|
||||||
|
case KeyboardFocusStandard, KeyboardFocusProminent:
|
||||||
|
normalized.KeyboardFocus = prefs.KeyboardFocus
|
||||||
|
}
|
||||||
|
normalized.ReducedMotion = prefs.ReducedMotion
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import "git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
|
||||||
|
type SourceMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceLocal SourceMode = "local"
|
||||||
|
SourceRemote SourceMode = "remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Direction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DirectionPull Direction = "pull"
|
||||||
|
DirectionPush Direction = "push"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DialogPurpose string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DialogPurposeAdvanced DialogPurpose = "advanced"
|
||||||
|
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MenuModel struct {
|
||||||
|
HasOpenVault bool
|
||||||
|
HasSelectedBinding bool
|
||||||
|
ShowSelectors bool
|
||||||
|
ShowShare bool
|
||||||
|
ShowSaveCurrentBinding bool
|
||||||
|
SavedBindingSummary MenuBindingSummary
|
||||||
|
RemoteBaseURL string
|
||||||
|
RemotePath string
|
||||||
|
RemoteUsername string
|
||||||
|
RemotePassword string
|
||||||
|
SelectedVaultSyncMode appstate.SyncMode
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuBindingSummary struct {
|
||||||
|
ProfileLabel string
|
||||||
|
CredentialLabel string
|
||||||
|
SyncLabel string
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) SavedBindingHeading() string {
|
||||||
|
if !m.ShowSelectors {
|
||||||
|
return "Use this vault's saved remote sync target"
|
||||||
|
}
|
||||||
|
return "Use a saved remote profile from this vault"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) OpenSelectedButtonLabel() string {
|
||||||
|
if !m.ShowSelectors {
|
||||||
|
return "Use Remote Sync"
|
||||||
|
}
|
||||||
|
return "Open Saved Remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
|
||||||
|
return m.HasOpenVault && m.HasSelectedBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
|
||||||
|
|
||||||
|
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
|
||||||
|
return m.HasOpenVault && m.HasSelectedBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
|
||||||
|
|
||||||
|
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
|
||||||
|
|
||||||
|
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
|
||||||
|
|
||||||
|
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
|
||||||
|
return m.HasOpenVault && !m.HasSelectedBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
|
||||||
|
|
||||||
|
func (m MenuModel) ActionLabels() []string {
|
||||||
|
labels := []string{"Open Advanced Sync"}
|
||||||
|
if m.ShowRemoteSyncSetupShortcut() {
|
||||||
|
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
|
||||||
|
}
|
||||||
|
if m.ShowDirectRemoteSyncShortcut() {
|
||||||
|
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
|
||||||
|
}
|
||||||
|
if m.ShowRemoteSyncSettingsShortcut() {
|
||||||
|
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
|
||||||
|
}
|
||||||
|
if m.ShowRemoveRemoteSyncShortcut() {
|
||||||
|
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
|
||||||
|
return "Bind this local vault to the current remote target"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
|
||||||
|
|
||||||
|
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
|
||||||
|
if purpose == DialogPurposeRemoteSetup {
|
||||||
|
return "Push this local vault to a WebDAV target and save that target for future sync."
|
||||||
|
}
|
||||||
|
sourceLabel := "another local vault file"
|
||||||
|
if source == SourceRemote {
|
||||||
|
sourceLabel = "another WebDAV-backed vault"
|
||||||
|
}
|
||||||
|
action := "Pull changes from"
|
||||||
|
if direction == DirectionPush {
|
||||||
|
action = "Push the current vault into"
|
||||||
|
}
|
||||||
|
return action + " " + sourceLabel + "."
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op/clip"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
)
|
|
||||||
|
|
||||||
type focusAppearance struct {
|
|
||||||
BorderColor color.NRGBA
|
|
||||||
OutlineColor color.NRGBA
|
|
||||||
OutlineWidth int
|
|
||||||
MinHeight int
|
|
||||||
}
|
|
||||||
|
|
||||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
|
||||||
prefs = normalizeAccessibilityPreferences(prefs)
|
|
||||||
appearance := focusAppearance{
|
|
||||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
|
||||||
OutlineColor: color.NRGBA{A: 0},
|
|
||||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
|
||||||
MinHeight: metric.Dp(unit.Dp(44)),
|
|
||||||
}
|
|
||||||
if prefs.DisplayDensity == displayDensityComfortable {
|
|
||||||
appearance.MinHeight = metric.Dp(unit.Dp(52))
|
|
||||||
}
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
|
|
||||||
}
|
|
||||||
if focused {
|
|
||||||
appearance.BorderColor = accentColor
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
|
||||||
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
|
|
||||||
}
|
|
||||||
if prefs.KeyboardFocus == keyboardFocusProminent {
|
|
||||||
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
|
|
||||||
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return appearance
|
|
||||||
}
|
|
||||||
|
|
||||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
|
||||||
prefs = normalizeAccessibilityPreferences(prefs)
|
|
||||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
|
||||||
text = accentColor
|
|
||||||
if prefs.Contrast == contrastHigh {
|
|
||||||
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
|
|
||||||
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
|
|
||||||
}
|
|
||||||
if focused {
|
|
||||||
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
|
||||||
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
|
|
||||||
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return background, text
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) accessibilityLabel(id focusID) string {
|
|
||||||
switch {
|
|
||||||
case id == focusSearch:
|
|
||||||
return "Search vault"
|
|
||||||
case strings.HasPrefix(string(id), "breadcrumb:"):
|
|
||||||
index := focusIndex(id)
|
|
||||||
crumbs := u.breadcrumbLabels()
|
|
||||||
if index >= 0 && index < len(crumbs) {
|
|
||||||
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(string(id), "list:"):
|
|
||||||
index := focusIndex(id)
|
|
||||||
if index >= 0 && index < len(u.visible) {
|
|
||||||
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(string(id), "detail:"):
|
|
||||||
name := strings.TrimPrefix(string(id), "detail:")
|
|
||||||
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
|
||||||
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
|
||||||
return layout.Dimensions{Size: size}
|
|
||||||
}
|
|
||||||
|
|
||||||
width := appearance.OutlineWidth
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
|
||||||
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
|
||||||
return layout.Dimensions{Size: size}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) isFocused(id focusID) bool {
|
|
||||||
return u.keyboardFocus == id
|
|
||||||
}
|
|
||||||
|
|
||||||
func detailFieldLabel(field detailField) string {
|
|
||||||
switch field {
|
|
||||||
case detailFieldID:
|
|
||||||
return "ID"
|
|
||||||
case detailFieldTitle:
|
|
||||||
return "Title"
|
|
||||||
case detailFieldUsername:
|
|
||||||
return "Username"
|
|
||||||
case detailFieldPassword:
|
|
||||||
return "Password"
|
|
||||||
case detailFieldURL:
|
|
||||||
return "URL"
|
|
||||||
case detailFieldPath:
|
|
||||||
return "Path"
|
|
||||||
case detailFieldTags:
|
|
||||||
return "Tags"
|
|
||||||
case detailFieldPasswordProfile:
|
|
||||||
return "Password Profile"
|
|
||||||
case detailFieldNotes:
|
|
||||||
return "Notes"
|
|
||||||
case detailFieldFields:
|
|
||||||
return "Custom Fields"
|
|
||||||
case detailFieldHistoryIndex:
|
|
||||||
return "History Index"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(string(field), "-", " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !u.usesCompactViewport() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
|
|
||||||
width := gtx.Dp(unit.Dp(widthDP))
|
|
||||||
height := gtx.Dp(unit.Dp(heightDP))
|
|
||||||
if width > gtx.Constraints.Max.X {
|
|
||||||
width = gtx.Constraints.Max.X
|
|
||||||
}
|
|
||||||
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
|
|
||||||
height = gtx.Constraints.Max.Y
|
|
||||||
}
|
|
||||||
img := widget.Image{
|
|
||||||
Src: src,
|
|
||||||
Fit: widget.Contain,
|
|
||||||
Position: layout.W,
|
|
||||||
Scale: 1.0 / gtx.Metric.PxPerDp,
|
|
||||||
}
|
|
||||||
gtx.Constraints.Min = image.Point{}
|
|
||||||
gtx.Constraints.Max = image.Pt(width, height)
|
|
||||||
return img.Layout(gtx)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
|
||||||
panel := card
|
|
||||||
if u.usesCompactViewport() {
|
|
||||||
panel = compactCard
|
|
||||||
}
|
|
||||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
u.lifecycleBranding,
|
|
||||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
|
||||||
u.lifecycleControls,
|
|
||||||
}
|
|
||||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
|
||||||
return rows[i](gtx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gioui.org/io/event"
|
|
||||||
"gioui.org/io/key"
|
|
||||||
"gioui.org/layout"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
shortcutSearch = "search"
|
|
||||||
shortcutSave = "save"
|
|
||||||
shortcutLock = "lock"
|
|
||||||
shortcutNewEntry = "new-entry"
|
|
||||||
shortcutCopyUser = "copy-user"
|
|
||||||
shortcutCopyPassword = "copy-password"
|
|
||||||
shortcutCopyURL = "copy-url"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) processShortcuts(gtx layout.Context) {
|
|
||||||
event.Op(gtx.Ops, u)
|
|
||||||
for {
|
|
||||||
ev, ok := gtx.Event(
|
|
||||||
key.Filter{Name: "F", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "S", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "L", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "N", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "U", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "P", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: "O", Required: key.ModShortcut},
|
|
||||||
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
|
||||||
key.Filter{Name: key.NameLeftArrow},
|
|
||||||
key.Filter{Name: key.NameRightArrow},
|
|
||||||
key.Filter{Name: key.NameUpArrow},
|
|
||||||
key.Filter{Name: key.NameDownArrow},
|
|
||||||
key.Filter{Name: key.NameReturn},
|
|
||||||
key.Filter{Name: key.NameBack},
|
|
||||||
key.Filter{Name: key.NameEscape},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
ke, ok := ev.(key.Event)
|
|
||||||
if !ok || ke.State != key.Press {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
u.handleKeyPress(ke.Name, ke.Modifiers)
|
|
||||||
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
|
||||||
_ = u.handlePhoneBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) performShortcut(name string) error {
|
|
||||||
switch name {
|
|
||||||
case shortcutSearch:
|
|
||||||
u.keyboardFocus = focusSearch
|
|
||||||
return nil
|
|
||||||
case shortcutSave:
|
|
||||||
return u.saveAction()
|
|
||||||
case shortcutLock:
|
|
||||||
return u.lockAction()
|
|
||||||
case shortcutNewEntry:
|
|
||||||
u.state.BeginNewEntry()
|
|
||||||
u.loadSelectedEntryIntoEditor()
|
|
||||||
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
|
||||||
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
|
||||||
return nil
|
|
||||||
case shortcutCopyUser:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
|
||||||
case shortcutCopyPassword:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetPassword)
|
|
||||||
case shortcutCopyURL:
|
|
||||||
return u.copySelectedFieldAction(clipboard.TargetURL)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
|
||||||
appuiactions "git.julianfamily.org/keepassgo/internal/appui/actions"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) buildSyncMenuModel() appuiactions.SyncMenuModel {
|
|
||||||
model := appuiactions.SyncMenuModel{
|
|
||||||
HasOpenVault: u.hasOpenVault(),
|
|
||||||
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
|
|
||||||
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
|
|
||||||
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
||||||
RemotePath: strings.TrimSpace(u.remotePath.Text()),
|
|
||||||
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
RemotePassword: u.remotePassword.Text(),
|
|
||||||
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
|
|
||||||
}
|
|
||||||
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
|
|
||||||
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
|
|
||||||
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) computeSavedRemoteBindingSummary() appuiactions.SyncMenuBindingSummary {
|
|
||||||
profile, ok := u.selectedVaultRemoteProfile()
|
|
||||||
if !ok {
|
|
||||||
return appuiactions.SyncMenuBindingSummary{}
|
|
||||||
}
|
|
||||||
entry, ok := u.selectedVaultRemoteCredentialEntry()
|
|
||||||
if !ok {
|
|
||||||
return appuiactions.SyncMenuBindingSummary{}
|
|
||||||
}
|
|
||||||
credentialLabel := entry.Title
|
|
||||||
if strings.TrimSpace(entry.Username) != "" {
|
|
||||||
credentialLabel += " · " + strings.TrimSpace(entry.Username)
|
|
||||||
}
|
|
||||||
syncLabel := "Sync manually when you choose Use Remote Sync."
|
|
||||||
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
|
|
||||||
syncLabel = "Syncs automatically on open and save."
|
|
||||||
}
|
|
||||||
return appuiactions.SyncMenuBindingSummary{
|
|
||||||
ProfileLabel: profile.Name,
|
|
||||||
CredentialLabel: credentialLabel,
|
|
||||||
SyncLabel: syncLabel,
|
|
||||||
OK: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user