2405 lines
72 KiB
Go
2405 lines
72 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gioui.org/app"
|
|
"gioui.org/gesture"
|
|
"gioui.org/io/pointer"
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
"git.julianfamily.org/keepassgo/appstate"
|
|
"git.julianfamily.org/keepassgo/clipboard"
|
|
"git.julianfamily.org/keepassgo/passwords"
|
|
"git.julianfamily.org/keepassgo/session"
|
|
"git.julianfamily.org/keepassgo/vault"
|
|
"git.julianfamily.org/keepassgo/webdav"
|
|
"golang.org/x/exp/shiny/materialdesign/icons"
|
|
)
|
|
|
|
type entry = vault.Entry
|
|
|
|
const (
|
|
productName = "KeePassGO"
|
|
)
|
|
|
|
const (
|
|
maxAttachmentBytes = 10 << 20
|
|
statusBannerDuration = 4 * time.Second
|
|
)
|
|
|
|
type bannerKind string
|
|
|
|
const (
|
|
bannerNone bannerKind = ""
|
|
bannerLoading bannerKind = "loading"
|
|
bannerError bannerKind = "error"
|
|
bannerStatus bannerKind = "status"
|
|
)
|
|
|
|
type uiBanner struct {
|
|
Kind bannerKind
|
|
Message string
|
|
}
|
|
|
|
type uiSurface struct {
|
|
Title string
|
|
Message string
|
|
Locked bool
|
|
}
|
|
|
|
type sessionStatus interface {
|
|
HasVault() bool
|
|
IsLocked() bool
|
|
IsRemote() bool
|
|
}
|
|
|
|
type attachmentItem struct {
|
|
Name string
|
|
Size int
|
|
}
|
|
|
|
type statePaths struct {
|
|
DefaultSaveAsPath string
|
|
RecentVaultsPath string
|
|
RecentRemotesPath string
|
|
}
|
|
|
|
type recentVaultRecord struct {
|
|
Path string `json:"path"`
|
|
LastGroup []string `json:"lastGroup,omitempty"`
|
|
}
|
|
|
|
type recentRemoteRecord struct {
|
|
BaseURL string `json:"baseUrl"`
|
|
Path string `json:"path"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
type ui struct {
|
|
mode string
|
|
theme *material.Theme
|
|
search widget.Editor
|
|
vaultPath widget.Editor
|
|
saveAsPath widget.Editor
|
|
remoteBaseURL widget.Editor
|
|
remotePath widget.Editor
|
|
remoteUsername widget.Editor
|
|
remotePassword widget.Editor
|
|
masterPassword widget.Editor
|
|
keyFilePath widget.Editor
|
|
entryID widget.Editor
|
|
entryTitle widget.Editor
|
|
entryUsername widget.Editor
|
|
entryPassword widget.Editor
|
|
entryURL widget.Editor
|
|
entryNotes widget.Editor
|
|
entryTags widget.Editor
|
|
entryPath widget.Editor
|
|
entryFields widget.Editor
|
|
customFieldKeys []widget.Editor
|
|
customFieldValues []widget.Editor
|
|
historyIndex widget.Editor
|
|
groupName widget.Editor
|
|
passwordProfile widget.Editor
|
|
attachmentName widget.Editor
|
|
attachmentPath widget.Editor
|
|
exportAttachmentPath widget.Editor
|
|
list widget.List
|
|
detailList widget.List
|
|
copyUser widget.Clickable
|
|
copyPass widget.Clickable
|
|
copyURL widget.Clickable
|
|
lockVault widget.Clickable
|
|
unlockVault widget.Clickable
|
|
createVault widget.Clickable
|
|
openVault widget.Clickable
|
|
saveVault widget.Clickable
|
|
saveAsVault widget.Clickable
|
|
openRemote widget.Clickable
|
|
changeMasterKey widget.Clickable
|
|
synchronizeVault widget.Clickable
|
|
editEntry widget.Clickable
|
|
cancelEdit widget.Clickable
|
|
pickVaultPath widget.Clickable
|
|
pickKeyFile widget.Clickable
|
|
addEntry widget.Clickable
|
|
saveEntry widget.Clickable
|
|
duplicateEntry widget.Clickable
|
|
deleteEntry widget.Clickable
|
|
restoreEntry widget.Clickable
|
|
saveTemplate widget.Clickable
|
|
deleteTemplate widget.Clickable
|
|
instantiateTemplate widget.Clickable
|
|
addAttachment widget.Clickable
|
|
replaceAttachment widget.Clickable
|
|
removeAttachment widget.Clickable
|
|
exportAttachment widget.Clickable
|
|
restoreHistory widget.Clickable
|
|
generatePassword widget.Clickable
|
|
createGroup widget.Clickable
|
|
renameGroup widget.Clickable
|
|
deleteGroup widget.Clickable
|
|
confirmDeleteGroup widget.Clickable
|
|
cancelDeleteGroup widget.Clickable
|
|
addCustomField widget.Clickable
|
|
toggleGroupControls widget.Clickable
|
|
togglePasswordInline widget.Clickable
|
|
showEntries widget.Clickable
|
|
showTemplates widget.Clickable
|
|
showRecycle widget.Clickable
|
|
showLocalLifecycle widget.Clickable
|
|
showRemoteLifecycle widget.Clickable
|
|
rememberRemoteAuth widget.Bool
|
|
entryClicks []widget.Clickable
|
|
historyClicks []widget.Clickable
|
|
attachmentClicks []widget.Clickable
|
|
breadcrumbs []widget.Clickable
|
|
groupClicks []widget.Clickable
|
|
recentVaultClicks []widget.Clickable
|
|
recentRemoteClicks []widget.Clickable
|
|
removeCustomFields []widget.Clickable
|
|
state appstate.State
|
|
visible []entry
|
|
currentPath []string
|
|
syncedPath []string
|
|
selectedHistoryIndex int
|
|
showPassword bool
|
|
togglePassword widget.Clickable
|
|
phoneSplit widget.Float
|
|
splitDrag gesture.Drag
|
|
splitBase float32
|
|
splitStartY float32
|
|
phoneSpan int
|
|
eyeIcon *widget.Icon
|
|
eyeOffIcon *widget.Icon
|
|
copyIcon *widget.Icon
|
|
expandMoreIcon *widget.Icon
|
|
expandLessIcon *widget.Icon
|
|
clipboardWriter clipboard.Writer
|
|
loadingMessage string
|
|
lifecycleMode string
|
|
keyboardFocus focusID
|
|
defaultSaveAsPath string
|
|
recentVaultsPath string
|
|
recentRemotesPath string
|
|
editingEntry bool
|
|
groupControlsHidden bool
|
|
recentVaults []string
|
|
recentRemotes []recentRemoteRecord
|
|
recentVaultGroups map[string][]string
|
|
deleteGroupPath []string
|
|
statusExpiresAt time.Time
|
|
now func() time.Time
|
|
}
|
|
|
|
var (
|
|
bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255}
|
|
panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255}
|
|
accentColor = color.NRGBA{R: 28, G: 83, B: 63, A: 255}
|
|
mutedColor = color.NRGBA{R: 95, G: 93, B: 88, A: 255}
|
|
selectedColor = color.NRGBA{R: 221, G: 233, B: 226, A: 255}
|
|
selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255}
|
|
)
|
|
|
|
const (
|
|
errVaultPathRequired = "vault path is required"
|
|
errSaveAsPathRequired = "save-as path is required"
|
|
)
|
|
|
|
func newUI(mode string, paths statePaths) *ui {
|
|
return newUIWithSession(mode, &session.Manager{}, paths)
|
|
}
|
|
|
|
func newUIWithModel(mode string, model vault.Model) *ui {
|
|
return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths(""))
|
|
}
|
|
|
|
func newUIWithSession(mode string, sess appstate.CurrentSession, paths ...statePaths) *ui {
|
|
selected := defaultStatePaths("")
|
|
if len(paths) > 0 {
|
|
selected = paths[0]
|
|
}
|
|
return newUIWithState(mode, sess, selected)
|
|
}
|
|
|
|
func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui {
|
|
th := material.NewTheme()
|
|
th.Palette.Bg = bgColor
|
|
th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255}
|
|
th.Palette.ContrastBg = accentColor
|
|
th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
|
|
u := &ui{
|
|
mode: mode,
|
|
theme: th,
|
|
search: widget.Editor{
|
|
SingleLine: true,
|
|
Submit: false,
|
|
},
|
|
vaultPath: widget.Editor{SingleLine: true, Submit: false},
|
|
saveAsPath: widget.Editor{SingleLine: true, Submit: false},
|
|
remoteBaseURL: widget.Editor{SingleLine: true, Submit: false},
|
|
remotePath: widget.Editor{SingleLine: true, Submit: false},
|
|
remoteUsername: widget.Editor{SingleLine: true, Submit: false},
|
|
remotePassword: widget.Editor{SingleLine: true, Submit: false},
|
|
masterPassword: widget.Editor{SingleLine: true, Submit: false},
|
|
keyFilePath: widget.Editor{SingleLine: true, Submit: false},
|
|
entryID: widget.Editor{SingleLine: true, Submit: false},
|
|
entryTitle: widget.Editor{SingleLine: true, Submit: false},
|
|
entryUsername: widget.Editor{SingleLine: true, Submit: false},
|
|
entryPassword: widget.Editor{SingleLine: true, Submit: false},
|
|
entryURL: widget.Editor{SingleLine: true, Submit: false},
|
|
entryNotes: widget.Editor{SingleLine: false, Submit: false},
|
|
entryTags: widget.Editor{SingleLine: true, Submit: false},
|
|
entryPath: widget.Editor{SingleLine: true, Submit: false},
|
|
entryFields: widget.Editor{SingleLine: false, Submit: false},
|
|
historyIndex: widget.Editor{SingleLine: true, Submit: false},
|
|
groupName: widget.Editor{SingleLine: true, Submit: false},
|
|
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
|
|
attachmentName: widget.Editor{SingleLine: true, Submit: false},
|
|
attachmentPath: widget.Editor{SingleLine: true, Submit: false},
|
|
exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false},
|
|
list: widget.List{
|
|
List: layout.List{Axis: layout.Vertical},
|
|
},
|
|
detailList: widget.List{
|
|
List: layout.List{Axis: layout.Vertical},
|
|
},
|
|
state: appstate.State{},
|
|
selectedHistoryIndex: -1,
|
|
lifecycleMode: "local",
|
|
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
|
recentVaultsPath: paths.RecentVaultsPath,
|
|
recentRemotesPath: paths.RecentRemotesPath,
|
|
recentVaultGroups: map[string][]string{},
|
|
now: time.Now,
|
|
}
|
|
u.state.Session = sess
|
|
u.phoneSplit.Value = 0.46
|
|
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
|
|
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
|
|
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
|
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
|
|
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
|
|
u.passwordProfile.SetText("strong")
|
|
u.keyboardFocus = focusSearch
|
|
u.setCustomFieldRows(nil)
|
|
u.loadRecentVaults()
|
|
u.loadRecentRemotes()
|
|
u.filter()
|
|
return u
|
|
}
|
|
|
|
func (u *ui) filter() {
|
|
u.state.SetSearchQuery(u.search.Text())
|
|
visible, err := u.state.VisibleEntries()
|
|
if err != nil {
|
|
u.visible = nil
|
|
return
|
|
}
|
|
u.visible = visible
|
|
if len(u.entryClicks) < len(u.visible) {
|
|
u.entryClicks = make([]widget.Clickable, len(u.visible))
|
|
}
|
|
}
|
|
|
|
func defaultStatePaths(stateDir string) statePaths {
|
|
baseDir := strings.TrimSpace(stateDir)
|
|
if baseDir == "" {
|
|
baseDir = strings.TrimSpace(os.Getenv("KEEPASSGO_STATE_DIR"))
|
|
}
|
|
if baseDir == "" {
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil || strings.TrimSpace(configDir) == "" {
|
|
configDir = os.TempDir()
|
|
}
|
|
baseDir = filepath.Join(configDir, "keepassgo")
|
|
}
|
|
return statePaths{
|
|
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
|
|
}
|
|
}
|
|
|
|
func resolveFlagOrEnv(flagValue, envName, fallback string) string {
|
|
if value := strings.TrimSpace(flagValue); value != "" {
|
|
return value
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (u *ui) selectedAttachmentItems() []attachmentItem {
|
|
item, ok := u.selectedEntry()
|
|
if !ok || len(item.Attachments) == 0 {
|
|
return nil
|
|
}
|
|
|
|
items := make([]attachmentItem, 0, len(item.Attachments))
|
|
for name, content := range item.Attachments {
|
|
items = append(items, attachmentItem{Name: name, Size: len(content)})
|
|
}
|
|
slices.SortFunc(items, func(a, b attachmentItem) int {
|
|
switch {
|
|
case a.Name < b.Name:
|
|
return -1
|
|
case a.Name > b.Name:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
if len(u.attachmentClicks) < len(items) {
|
|
u.attachmentClicks = make([]widget.Clickable, len(items))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (u *ui) selectedAttachmentNames() []string {
|
|
items := u.selectedAttachmentItems()
|
|
names := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
names = append(names, item.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (u *ui) showEntriesSection() {
|
|
u.resetPasswordPeek()
|
|
u.state.ShowSection(appstate.SectionEntries)
|
|
u.filter()
|
|
}
|
|
|
|
func (u *ui) showTemplatesSection() {
|
|
u.resetPasswordPeek()
|
|
u.state.ShowSection(appstate.SectionTemplates)
|
|
u.filter()
|
|
}
|
|
|
|
func (u *ui) showRecycleBinSection() {
|
|
u.resetPasswordPeek()
|
|
u.state.ShowSection(appstate.SectionRecycleBin)
|
|
u.filter()
|
|
}
|
|
|
|
func (u *ui) resetPasswordPeek() {
|
|
u.showPassword = false
|
|
}
|
|
|
|
func (u *ui) childGroups() []string {
|
|
u.state.SetSearchQuery(u.search.Text())
|
|
groups, err := u.state.ChildGroups()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func (u *ui) passwordProfileOptionsText() string {
|
|
return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ")
|
|
}
|
|
|
|
func (u *ui) filteredTitles() []string {
|
|
titles := make([]string, 0, len(u.visible))
|
|
for _, item := range u.visible {
|
|
titles = append(titles, item.Title)
|
|
}
|
|
return titles
|
|
}
|
|
|
|
func (u *ui) visiblePathContexts() []string {
|
|
paths := make([]string, 0, len(u.visible))
|
|
for _, item := range u.visible {
|
|
paths = append(paths, u.state.SearchPathContext(item))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func (u *ui) selectedEntry() (entry, bool) {
|
|
for _, item := range u.visible {
|
|
if item.ID == u.state.SelectedEntryID {
|
|
return item, true
|
|
}
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return entry{}, false
|
|
}
|
|
for _, item := range model.Entries {
|
|
if item.ID == u.state.SelectedEntryID {
|
|
return item, true
|
|
}
|
|
}
|
|
for _, item := range model.Templates {
|
|
if item.ID == u.state.SelectedEntryID {
|
|
return item, true
|
|
}
|
|
}
|
|
for _, item := range model.RecycleBin {
|
|
if item.ID == u.state.SelectedEntryID {
|
|
return item, true
|
|
}
|
|
}
|
|
return entry{}, false
|
|
}
|
|
|
|
func (u *ui) ensureHistoryClickables() {
|
|
history := u.visibleHistory()
|
|
if len(u.historyClicks) < len(history) {
|
|
u.historyClicks = make([]widget.Clickable, len(history))
|
|
}
|
|
}
|
|
|
|
func (u *ui) currentMasterKey() (vault.MasterKey, error) {
|
|
password := u.masterPassword.Text()
|
|
path := strings.TrimSpace(u.keyFilePath.Text())
|
|
if password == "" && path == "" {
|
|
return vault.MasterKey{}, fmt.Errorf("master password or key file is required")
|
|
}
|
|
if path == "" {
|
|
return vault.MasterKey{Password: password}, nil
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return vault.MasterKey{}, fmt.Errorf("read key file: %w", err)
|
|
}
|
|
if len(content) == 0 {
|
|
return vault.MasterKey{}, fmt.Errorf("key file is empty")
|
|
}
|
|
|
|
return vault.MasterKey{
|
|
Password: password,
|
|
KeyFileData: content,
|
|
}, nil
|
|
}
|
|
|
|
func (u *ui) setMasterKeyMode(vault.MasterKeyMode) {}
|
|
|
|
func (u *ui) createVaultAction() error {
|
|
key, err := u.currentMasterKey()
|
|
defer u.clearMasterPassword()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := u.state.CreateVault(key); err != nil {
|
|
return err
|
|
}
|
|
if u.lifecycleMode == "local" {
|
|
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.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.editingEntry = false
|
|
u.filter()
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) saveAction() error {
|
|
if err := u.state.Save(); 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
|
|
}
|
|
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
|
|
}
|
|
u.noteRecentRemote(
|
|
strings.TrimSpace(u.remoteBaseURL.Text()),
|
|
strings.TrimSpace(u.remotePath.Text()),
|
|
strings.TrimSpace(u.remoteUsername.Text()),
|
|
u.remotePassword.Text(),
|
|
u.rememberRemoteAuth.Value,
|
|
)
|
|
u.resetPasswordPeek()
|
|
u.enterHiddenVaultRoot()
|
|
u.editingEntry = false
|
|
u.filter()
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) lockAction() error {
|
|
u.clearMasterPassword()
|
|
if err := u.state.Lock(); err != nil {
|
|
return err
|
|
}
|
|
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.editingEntry = false
|
|
u.filter()
|
|
return 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) clearMasterPassword() {
|
|
u.masterPassword.SetText("")
|
|
}
|
|
|
|
func (u *ui) synchronizeAction() error {
|
|
if err := u.state.Synchronize(); err != nil {
|
|
return err
|
|
}
|
|
u.filter()
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) saveAsTargetPath() string {
|
|
path := strings.TrimSpace(u.saveAsPath.Text())
|
|
if path != "" {
|
|
return path
|
|
}
|
|
return u.defaultSaveAsPath
|
|
}
|
|
|
|
func (u *ui) noteRecentVault(path string) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return
|
|
}
|
|
if u.recentVaultGroups == nil {
|
|
u.recentVaultGroups = map[string][]string{}
|
|
}
|
|
if len(u.currentPath) > 0 {
|
|
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
|
|
} else if _, ok := u.recentVaultGroups[path]; !ok {
|
|
u.recentVaultGroups[path] = nil
|
|
}
|
|
next := []string{path}
|
|
for _, existing := range u.recentVaults {
|
|
if existing == path {
|
|
continue
|
|
}
|
|
next = append(next, existing)
|
|
if len(next) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentVaults = next
|
|
if len(u.recentVaultClicks) < len(u.recentVaults) {
|
|
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
|
|
}
|
|
u.saveRecentVaults()
|
|
}
|
|
|
|
func (u *ui) loadRecentVaults() {
|
|
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
|
return
|
|
}
|
|
content, err := os.ReadFile(u.recentVaultsPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
u.recentVaults = nil
|
|
u.recentVaultGroups = map[string][]string{}
|
|
var records []recentVaultRecord
|
|
switch {
|
|
case json.Unmarshal(content, &records) == nil:
|
|
u.applyRecentVaultRecords(records)
|
|
return
|
|
default:
|
|
var paths []string
|
|
if err := json.Unmarshal(content, &paths); err != nil {
|
|
return
|
|
}
|
|
records = make([]recentVaultRecord, 0, len(paths))
|
|
for _, path := range paths {
|
|
records = append(records, recentVaultRecord{Path: path})
|
|
}
|
|
u.applyRecentVaultRecords(records)
|
|
}
|
|
}
|
|
|
|
func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) {
|
|
filtered := make([]string, 0, len(records))
|
|
seen := map[string]bool{}
|
|
for _, record := range records {
|
|
path := strings.TrimSpace(record.Path)
|
|
if path == "" || seen[path] {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
filtered = append(filtered, path)
|
|
if u.recentVaultGroups == nil {
|
|
u.recentVaultGroups = map[string][]string{}
|
|
}
|
|
u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...)
|
|
if len(filtered) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentVaults = filtered
|
|
if len(u.recentVaultClicks) < len(u.recentVaults) {
|
|
u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults))
|
|
}
|
|
}
|
|
|
|
func (u *ui) loadRecentRemotes() {
|
|
if strings.TrimSpace(u.recentRemotesPath) == "" {
|
|
return
|
|
}
|
|
content, err := os.ReadFile(u.recentRemotesPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var records []recentRemoteRecord
|
|
if err := json.Unmarshal(content, &records); err != nil {
|
|
return
|
|
}
|
|
filtered := make([]recentRemoteRecord, 0, len(records))
|
|
seen := map[string]bool{}
|
|
for _, record := range records {
|
|
record.BaseURL = strings.TrimSpace(record.BaseURL)
|
|
record.Path = strings.TrimSpace(record.Path)
|
|
if record.BaseURL == "" || record.Path == "" {
|
|
continue
|
|
}
|
|
key := record.BaseURL + "|" + record.Path
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
filtered = append(filtered, record)
|
|
if len(filtered) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentRemotes = filtered
|
|
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
|
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
|
}
|
|
}
|
|
|
|
func (u *ui) saveRecentVaults() {
|
|
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil {
|
|
return
|
|
}
|
|
records := make([]recentVaultRecord, 0, len(u.recentVaults))
|
|
for _, path := range u.recentVaults {
|
|
records = append(records, recentVaultRecord{
|
|
Path: path,
|
|
LastGroup: append([]string(nil), u.recentVaultGroups[path]...),
|
|
})
|
|
}
|
|
content, err := json.MarshalIndent(records, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
|
|
}
|
|
|
|
func (u *ui) saveRecentRemotes() {
|
|
if strings.TrimSpace(u.recentRemotesPath) == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil {
|
|
return
|
|
}
|
|
content, err := json.MarshalIndent(u.recentRemotes, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(u.recentRemotesPath, content, 0o600)
|
|
}
|
|
|
|
func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) {
|
|
baseURL = strings.TrimSpace(baseURL)
|
|
path = strings.TrimSpace(path)
|
|
if baseURL == "" || path == "" {
|
|
return
|
|
}
|
|
record := recentRemoteRecord{
|
|
BaseURL: baseURL,
|
|
Path: path,
|
|
}
|
|
if rememberAuth {
|
|
record.Username = strings.TrimSpace(username)
|
|
record.Password = password
|
|
}
|
|
next := []recentRemoteRecord{record}
|
|
for _, existing := range u.recentRemotes {
|
|
if existing.BaseURL == baseURL && existing.Path == path {
|
|
continue
|
|
}
|
|
next = append(next, existing)
|
|
if len(next) == 6 {
|
|
break
|
|
}
|
|
}
|
|
u.recentRemotes = next
|
|
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
|
|
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
|
|
}
|
|
u.saveRecentRemotes()
|
|
}
|
|
|
|
func (u *ui) recentVaultGroup(path string) []string {
|
|
if u.recentVaultGroups == nil {
|
|
return nil
|
|
}
|
|
return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...)
|
|
}
|
|
|
|
func (u *ui) hiddenVaultRoot() string {
|
|
if u.state.Section != appstate.SectionEntries {
|
|
return ""
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if len(model.EntriesInPath(nil)) != 0 {
|
|
return ""
|
|
}
|
|
groups := model.ChildGroups(nil)
|
|
if len(groups) != 1 {
|
|
return ""
|
|
}
|
|
return groups[0]
|
|
}
|
|
|
|
func (u *ui) enterHiddenVaultRoot() {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" {
|
|
return
|
|
}
|
|
u.setCurrentPath([]string{root})
|
|
}
|
|
|
|
func (u *ui) restoreRecentVaultGroup(path string) {
|
|
saved := u.recentVaultGroup(path)
|
|
if len(saved) == 0 {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
u.enterHiddenVaultRoot()
|
|
return
|
|
}
|
|
root := u.hiddenVaultRoot()
|
|
if len(saved) == 1 && root != "" && saved[0] == root {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
|
u.setCurrentPath(saved)
|
|
return
|
|
}
|
|
u.enterHiddenVaultRoot()
|
|
}
|
|
|
|
func (u *ui) displayPath() []string {
|
|
path := append([]string(nil), u.currentPath...)
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" || len(path) == 0 || path[0] != root {
|
|
return path
|
|
}
|
|
return append([]string(nil), path[1:]...)
|
|
}
|
|
|
|
func (u *ui) displayEntryPath(path []string) []string {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" || len(path) == 0 || path[0] != root {
|
|
return append([]string(nil), path...)
|
|
}
|
|
return append([]string(nil), path[1:]...)
|
|
}
|
|
|
|
func pathHasPrefix(path, prefix []string) bool {
|
|
if len(prefix) > len(path) {
|
|
return false
|
|
}
|
|
return slices.Equal(path[:len(prefix)], prefix)
|
|
}
|
|
|
|
func hasExactGroup(model vault.Model, path []string) bool {
|
|
for _, group := range model.Groups {
|
|
if slices.Equal(group, path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (u *ui) currentGroupDeletionState() (bool, string) {
|
|
u.syncCurrentPath()
|
|
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
|
|
return false, ""
|
|
}
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
path := append([]string(nil), u.currentPath...)
|
|
if len(model.ChildGroups(path)) > 0 {
|
|
return false, "This group contains child groups. Move or delete them before removing the group."
|
|
}
|
|
for _, item := range model.Entries {
|
|
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
|
return false, "This group contains entries. Move or delete them before removing the group."
|
|
}
|
|
}
|
|
for _, item := range model.Templates {
|
|
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
|
return false, "This group contains templates. Move or delete them before removing the group."
|
|
}
|
|
}
|
|
return true, "Deleting this empty group will not remove any entries."
|
|
}
|
|
|
|
func (u *ui) deleteGroupPendingConfirmation() bool {
|
|
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
|
}
|
|
|
|
func (u *ui) clearDeleteGroupConfirmation() {
|
|
u.deleteGroupPath = nil
|
|
}
|
|
|
|
func (u *ui) armDeleteCurrentGroupAction() {
|
|
if deletable, _ := u.currentGroupDeletionState(); !deletable {
|
|
return
|
|
}
|
|
u.syncCurrentPath()
|
|
u.deleteGroupPath = append([]string(nil), u.currentPath...)
|
|
u.state.ErrorMessage = ""
|
|
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))
|
|
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
|
}
|
|
|
|
func (u *ui) runAction(label string, action func() error) {
|
|
u.loadingMessage = actionLoadingLabel(label)
|
|
if err := action(); err != nil {
|
|
u.loadingMessage = ""
|
|
u.state.ErrorMessage = u.describeActionError(label, err)
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
u.loadingMessage = ""
|
|
u.state.ErrorMessage = ""
|
|
if suppressStatusMessage(label) {
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return
|
|
}
|
|
u.state.StatusMessage = label + " complete"
|
|
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
|
}
|
|
|
|
func suppressStatusMessage(label string) bool {
|
|
switch strings.TrimSpace(label) {
|
|
case "open vault", "open remote vault":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func actionLoadingLabel(label string) string {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return "Working..."
|
|
}
|
|
runes := []rune(label)
|
|
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
|
|
return string(runes) + "..."
|
|
}
|
|
|
|
func (u *ui) describeActionError(label string, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) {
|
|
return "Save conflict: the remote vault changed. Reopen it and retry the save."
|
|
}
|
|
if label == "open remote vault" {
|
|
return fmt.Sprintf("%s failed: %v", label, err)
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func (u *ui) bannerSurface() uiBanner {
|
|
switch {
|
|
case strings.TrimSpace(u.loadingMessage) != "":
|
|
return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)}
|
|
case strings.TrimSpace(u.state.ErrorMessage) != "":
|
|
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
|
|
case strings.TrimSpace(u.state.StatusMessage) != "":
|
|
if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) {
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
return uiBanner{}
|
|
}
|
|
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
|
|
default:
|
|
return uiBanner{}
|
|
}
|
|
}
|
|
|
|
func (u *ui) sessionSurface() uiSurface {
|
|
if u.state.Session == nil {
|
|
return uiSurface{}
|
|
}
|
|
if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) {
|
|
return uiSurface{
|
|
Title: "Vault locked",
|
|
Message: "Enter a master password, choose a key file, or provide both to unlock the vault.",
|
|
Locked: true,
|
|
}
|
|
}
|
|
return uiSurface{}
|
|
}
|
|
|
|
func (u *ui) hasOpenVault() bool {
|
|
status, ok := u.state.Session.(sessionStatus)
|
|
if ok {
|
|
return status.HasVault()
|
|
}
|
|
_, err := u.state.Session.Current()
|
|
return err == nil
|
|
}
|
|
|
|
func (u *ui) isVaultLocked() bool {
|
|
status, ok := u.state.Session.(sessionStatus)
|
|
if ok {
|
|
return status.IsLocked()
|
|
}
|
|
_, err := u.state.Session.Current()
|
|
return errors.Is(err, session.ErrLocked)
|
|
}
|
|
|
|
func (u *ui) shouldShowLifecycleSetup() bool {
|
|
return !u.hasOpenVault()
|
|
}
|
|
|
|
func (u *ui) shouldUseLockedSinglePane() bool {
|
|
return u.isVaultLocked() && !u.shouldShowLifecycleSetup()
|
|
}
|
|
|
|
func (u *ui) shouldShowDesktopWorkingHeader() bool {
|
|
return u.mode == "desktop" && !u.shouldShowLifecycleSetup() && !u.isVaultLocked()
|
|
}
|
|
|
|
func (u *ui) shouldUseCompactPhoneDetailPane() bool {
|
|
if u.mode != "phone" {
|
|
return false
|
|
}
|
|
if u.isVaultLocked() || u.editingEntry {
|
|
return false
|
|
}
|
|
_, ok := u.selectedEntry()
|
|
return !ok
|
|
}
|
|
|
|
func (u *ui) chooseExistingFileAction(target *widget.Editor) error {
|
|
path, err := pickExistingFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target.SetText(path)
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) listEmptyMessage() string {
|
|
if surface := u.sessionSurface(); surface.Locked {
|
|
return "Unlock the vault to browse entries and groups."
|
|
}
|
|
query := strings.TrimSpace(u.search.Text())
|
|
if query != "" {
|
|
switch u.state.Section {
|
|
case appstate.SectionTemplates:
|
|
return fmt.Sprintf("No templates match %q. Clear or refine the search.", query)
|
|
case appstate.SectionRecycleBin:
|
|
return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query)
|
|
default:
|
|
return fmt.Sprintf("No entries match %q. Clear or refine the search.", query)
|
|
}
|
|
}
|
|
switch u.state.Section {
|
|
case appstate.SectionTemplates:
|
|
return "Templates are not available in this build."
|
|
case appstate.SectionRecycleBin:
|
|
return "Recycle Bin is empty."
|
|
default:
|
|
return "Create or open a vault, then add an entry to get started."
|
|
}
|
|
}
|
|
|
|
func (u *ui) detailPlaceholderMessage() string {
|
|
if surface := u.sessionSurface(); surface.Locked {
|
|
return "Unlock the vault to inspect entries, attachments, and history."
|
|
}
|
|
if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" ||
|
|
strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" ||
|
|
strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" {
|
|
return "Complete the form to create a new item or update the current selection."
|
|
}
|
|
switch u.state.Section {
|
|
case appstate.SectionTemplates:
|
|
return "Select a template or start a reusable entry."
|
|
case appstate.SectionRecycleBin:
|
|
return "Select a recycle-bin entry to review or restore it."
|
|
default:
|
|
return "Select an entry or start a new one."
|
|
}
|
|
}
|
|
|
|
func (u *ui) ensureNavClickables() {
|
|
u.syncCurrentPath()
|
|
if len(u.breadcrumbs) < len(u.currentPath)+1 {
|
|
u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1)
|
|
}
|
|
}
|
|
|
|
func (u *ui) setCurrentPath(path []string) {
|
|
u.currentPath = append([]string(nil), path...)
|
|
u.state.NavigateToPath(path)
|
|
u.syncedPath = append([]string(nil), path...)
|
|
u.noteCurrentVaultPath()
|
|
u.clearDeleteGroupConfirmation()
|
|
}
|
|
|
|
func (u *ui) syncCurrentPath() {
|
|
switch {
|
|
case slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
|
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
case !slices.Equal(u.currentPath, u.syncedPath) && slices.Equal(u.state.CurrentPath, u.syncedPath):
|
|
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
|
case !slices.Equal(u.currentPath, u.syncedPath) && !slices.Equal(u.state.CurrentPath, u.syncedPath):
|
|
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
|
}
|
|
u.syncedPath = append([]string(nil), u.currentPath...)
|
|
u.noteCurrentVaultPath()
|
|
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
|
|
u.clearDeleteGroupConfirmation()
|
|
}
|
|
}
|
|
|
|
func (u *ui) noteCurrentVaultPath() {
|
|
status, ok := u.state.Session.(sessionStatus)
|
|
if !ok || status.IsRemote() || status.IsLocked() {
|
|
return
|
|
}
|
|
path := strings.TrimSpace(u.vaultPath.Text())
|
|
if path == "" {
|
|
return
|
|
}
|
|
if u.recentVaultGroups == nil {
|
|
u.recentVaultGroups = map[string][]string{}
|
|
}
|
|
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
|
|
u.saveRecentVaults()
|
|
}
|
|
|
|
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|
u.processShortcuts(gtx)
|
|
for u.createVault.Clicked(gtx) {
|
|
u.runAction("create vault", u.createVaultAction)
|
|
}
|
|
for u.openVault.Clicked(gtx) {
|
|
u.runAction("open vault", u.openVaultAction)
|
|
}
|
|
for u.saveVault.Clicked(gtx) {
|
|
u.runAction("save vault", u.saveAction)
|
|
}
|
|
for u.saveAsVault.Clicked(gtx) {
|
|
u.runAction("save-as vault", u.saveAsAction)
|
|
}
|
|
for u.openRemote.Clicked(gtx) {
|
|
u.runAction("open remote vault", u.openRemoteAction)
|
|
}
|
|
for u.changeMasterKey.Clicked(gtx) {
|
|
u.runAction("change master key", u.changeMasterKeyAction)
|
|
}
|
|
for u.synchronizeVault.Clicked(gtx) {
|
|
u.runAction("synchronize vault", u.synchronizeAction)
|
|
}
|
|
for u.unlockVault.Clicked(gtx) {
|
|
u.runAction("unlock vault", u.unlockAction)
|
|
}
|
|
for u.showEntries.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.showEntriesSection()
|
|
}
|
|
for u.showTemplates.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.showTemplatesSection()
|
|
}
|
|
for u.showRecycle.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.showRecycleBinSection()
|
|
}
|
|
for u.showLocalLifecycle.Clicked(gtx) {
|
|
u.lifecycleMode = "local"
|
|
}
|
|
for u.showRemoteLifecycle.Clicked(gtx) {
|
|
u.lifecycleMode = "remote"
|
|
}
|
|
for u.lockVault.Clicked(gtx) {
|
|
u.runAction("lock vault", u.lockAction)
|
|
}
|
|
for u.editEntry.Clicked(gtx) {
|
|
u.editingEntry = true
|
|
u.loadSelectedEntryIntoEditor()
|
|
}
|
|
for u.cancelEdit.Clicked(gtx) {
|
|
u.editingEntry = false
|
|
u.loadSelectedEntryIntoEditor()
|
|
}
|
|
for u.pickVaultPath.Clicked(gtx) {
|
|
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
|
|
}
|
|
for u.pickKeyFile.Clicked(gtx) {
|
|
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
|
|
}
|
|
for i := range u.recentVaultClicks {
|
|
for u.recentVaultClicks[i].Clicked(gtx) {
|
|
if i < len(u.recentVaults) {
|
|
u.vaultPath.SetText(u.recentVaults[i])
|
|
}
|
|
}
|
|
}
|
|
for i := range u.recentRemoteClicks {
|
|
for u.recentRemoteClicks[i].Clicked(gtx) {
|
|
if i < len(u.recentRemotes) {
|
|
record := u.recentRemotes[i]
|
|
u.remoteBaseURL.SetText(record.BaseURL)
|
|
u.remotePath.SetText(record.Path)
|
|
u.remoteUsername.SetText(record.Username)
|
|
u.remotePassword.SetText(record.Password)
|
|
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
|
|
}
|
|
}
|
|
}
|
|
for u.addEntry.Clicked(gtx) {
|
|
u.state.BeginNewEntry()
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
|
|
u.editingEntry = true
|
|
}
|
|
for u.saveEntry.Clicked(gtx) {
|
|
u.runAction("save entry", u.saveEntryAction)
|
|
}
|
|
for u.duplicateEntry.Clicked(gtx) {
|
|
u.runAction("duplicate entry", u.duplicateSelectedEntryAction)
|
|
}
|
|
for u.deleteEntry.Clicked(gtx) {
|
|
u.runAction("delete entry", u.deleteSelectedEntryAction)
|
|
}
|
|
for u.restoreEntry.Clicked(gtx) {
|
|
u.runAction("restore entry", u.restoreSelectedRecycleEntryAction)
|
|
}
|
|
for u.saveTemplate.Clicked(gtx) {
|
|
u.runAction("save template", u.saveTemplateAction)
|
|
}
|
|
for u.deleteTemplate.Clicked(gtx) {
|
|
u.runAction("delete template", u.deleteSelectedTemplateAction)
|
|
}
|
|
for u.instantiateTemplate.Clicked(gtx) {
|
|
u.runAction("instantiate template", u.instantiateSelectedTemplateAction)
|
|
}
|
|
for u.addAttachment.Clicked(gtx) {
|
|
u.runAction("add attachment", u.addAttachmentAction)
|
|
}
|
|
for u.replaceAttachment.Clicked(gtx) {
|
|
u.runAction("replace attachment", u.replaceAttachmentAction)
|
|
}
|
|
for u.removeAttachment.Clicked(gtx) {
|
|
u.runAction("remove attachment", u.removeAttachmentAction)
|
|
}
|
|
for u.exportAttachment.Clicked(gtx) {
|
|
u.runAction("export attachment", u.exportAttachmentAction)
|
|
}
|
|
for u.copyUser.Clicked(gtx) {
|
|
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
|
|
}
|
|
for u.copyPass.Clicked(gtx) {
|
|
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
|
|
}
|
|
for u.copyURL.Clicked(gtx) {
|
|
u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) })
|
|
}
|
|
for u.generatePassword.Clicked(gtx) {
|
|
u.runAction("generate password", u.generatePasswordAction)
|
|
}
|
|
for u.restoreHistory.Clicked(gtx) {
|
|
u.runAction("restore history", u.restoreSelectedHistoryAction)
|
|
}
|
|
for u.createGroup.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.runAction("create group", u.createGroupAction)
|
|
}
|
|
for u.toggleGroupControls.Clicked(gtx) {
|
|
u.groupControlsHidden = !u.groupControlsHidden
|
|
}
|
|
for u.renameGroup.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.runAction("rename group", u.renameGroupAction)
|
|
}
|
|
for u.deleteGroup.Clicked(gtx) {
|
|
u.armDeleteCurrentGroupAction()
|
|
}
|
|
for u.confirmDeleteGroup.Clicked(gtx) {
|
|
u.runAction("delete group", u.deleteCurrentGroupAction)
|
|
u.clearDeleteGroupConfirmation()
|
|
}
|
|
for u.cancelDeleteGroup.Clicked(gtx) {
|
|
u.clearDeleteGroupConfirmation()
|
|
u.state.StatusMessage = ""
|
|
u.statusExpiresAt = time.Time{}
|
|
}
|
|
for u.togglePassword.Clicked(gtx) {
|
|
u.showPassword = !u.showPassword
|
|
}
|
|
for u.togglePasswordInline.Clicked(gtx) {
|
|
u.showPassword = !u.showPassword
|
|
}
|
|
if _, changed := u.search.Update(gtx); changed {
|
|
u.filter()
|
|
}
|
|
inset := layout.UniformInset(unit.Dp(16))
|
|
return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions {
|
|
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(u.header),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.bannerSurface().Kind == bannerNone {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
layout.Rigid(u.banner),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
)
|
|
}),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
if u.shouldShowLifecycleSetup() {
|
|
return layout.Dimensions{}
|
|
}
|
|
if u.shouldUseLockedSinglePane() {
|
|
return u.detailPanel(gtx)
|
|
}
|
|
if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) {
|
|
u.phoneSpan = gtx.Constraints.Max.Y
|
|
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
|
|
if listHeight < gtx.Dp(unit.Dp(180)) {
|
|
listHeight = gtx.Dp(unit.Dp(180))
|
|
}
|
|
if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) {
|
|
listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220))
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
gtx.Constraints.Min.Y = listHeight
|
|
gtx.Constraints.Max.Y = listHeight
|
|
return u.listPanel(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(u.phoneSlider),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
func() layout.FlexChild {
|
|
if u.shouldUseCompactPhoneDetailPane() {
|
|
return layout.Rigid(u.detailPanel)
|
|
}
|
|
return layout.Flexed(1, u.detailPanel)
|
|
}(),
|
|
)
|
|
}
|
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
|
layout.Flexed(0.38, u.listPanel),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
|
layout.Flexed(0.62, u.detailPanel),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|
if u.mode == "phone" {
|
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(20), productName)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(u.headerActions),
|
|
)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !u.shouldShowLifecycleSetup() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(u.lifecycleControls),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(24), productName)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(u.headerActions),
|
|
)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if !u.shouldShowLifecycleSetup() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
layout.Rigid(u.lifecycleControls),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
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{}
|
|
}
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
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")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|
panel := card
|
|
spacing := unit.Dp(12)
|
|
if u.mode == "phone" {
|
|
panel = compactCard
|
|
spacing = unit.Dp(8)
|
|
}
|
|
u.ensureNavClickables()
|
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.navigationHeader(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.pathBar(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupBar(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupControlsSection(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.mode == "phone" {
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
}
|
|
return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
|
editor := material.Editor(u.theme, &u.search, "Search vault")
|
|
editor.Color = u.theme.Palette.Fg
|
|
editor.HintColor = mutedColor
|
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
|
|
})
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
|
return layout.Dimensions{}
|
|
}
|
|
label := "Add Entry"
|
|
if u.mode == "phone" {
|
|
label = "+ " + label
|
|
}
|
|
btn := material.Button(u.theme, &u.addEntry, label)
|
|
return btn.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
if len(u.visible) == 0 {
|
|
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}
|
|
return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions {
|
|
item := u.visible[i]
|
|
click := &u.entryClicks[i]
|
|
return u.entryRow(gtx, click, i, item)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return u.sectionBar(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section != appstate.SectionEntries {
|
|
return layout.Dimensions{}
|
|
}
|
|
return u.groupControlsDisclosure(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
|
|
for click.Clicked(gtx) {
|
|
_ = u.state.ToggleVisibleIndex(idx)
|
|
u.loadSelectedEntryIntoEditor()
|
|
}
|
|
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
inset := unit.Dp(12)
|
|
titleSize := unit.Sp(18)
|
|
metaSize := unit.Sp(14)
|
|
urlSize := unit.Sp(13)
|
|
if u.mode == "phone" {
|
|
inset = unit.Dp(10)
|
|
titleSize = unit.Sp(16)
|
|
metaSize = unit.Sp(13)
|
|
urlSize = unit.Sp(12)
|
|
}
|
|
row := func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, titleSize, item.Title)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, metaSize, item.Username)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, urlSize, item.URL)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if strings.TrimSpace(u.search.Text()) == "" {
|
|
return layout.Dimensions{}
|
|
}
|
|
lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / "))
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
w := gtx.Constraints.Max.X
|
|
if w < 1 {
|
|
w = 1
|
|
}
|
|
paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op())
|
|
return layout.Dimensions{Size: image.Pt(w, 1)}
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
size := gtx.Constraints.Min
|
|
if size.X == 0 {
|
|
size.X = gtx.Constraints.Max.X
|
|
}
|
|
if size.Y == 0 {
|
|
size.Y = gtx.Constraints.Max.Y
|
|
}
|
|
fillColor := selectedColor
|
|
edgeColor := selectedEdge
|
|
if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID {
|
|
fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255}
|
|
edgeColor = accentColor
|
|
}
|
|
paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op())
|
|
paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
|
|
return layout.Dimensions{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
|
return row(gtx)
|
|
}),
|
|
)
|
|
}
|
|
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
|
|
return row(gtx)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions {
|
|
if u.mode != "phone" {
|
|
return layout.Dimensions{}
|
|
}
|
|
for {
|
|
e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical)
|
|
if !ok {
|
|
break
|
|
}
|
|
switch e.Kind {
|
|
case pointer.Press:
|
|
u.splitBase = u.phoneSplit.Value
|
|
u.splitStartY = e.Position.Y
|
|
case pointer.Drag:
|
|
if u.phoneSpan > 0 {
|
|
next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan)
|
|
if next < 0.28 {
|
|
next = 0.28
|
|
}
|
|
if next > 0.72 {
|
|
next = 0.72
|
|
}
|
|
u.phoneSplit.Value = next
|
|
}
|
|
}
|
|
}
|
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(18))
|
|
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(18))
|
|
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
|
u.splitDrag.Add(gtx.Ops)
|
|
pointer.CursorRowResize.Add(gtx.Ops)
|
|
handleW := gtx.Dp(unit.Dp(84))
|
|
handleH := gtx.Dp(unit.Dp(4))
|
|
x := (gtx.Constraints.Min.X - handleW) / 2
|
|
y := (gtx.Constraints.Min.Y - handleH) / 2
|
|
paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.Op())
|
|
paint.FillShape(gtx.Ops, accentColor, clip.RRect{
|
|
Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x+handleW, y+handleH)},
|
|
NE: 2, NW: 2, SE: 2, SW: 2,
|
|
}.Op(gtx.Ops))
|
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
|
})
|
|
}
|
|
|
|
func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
|
panel := card
|
|
if u.mode == "phone" {
|
|
panel = compactCard
|
|
}
|
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
if u.shouldShowDesktopWorkingHeader() {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Dimensions{}
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.synchronizeVault, "Synchronize")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
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")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return u.detailPanelContent(gtx)
|
|
}),
|
|
)
|
|
}
|
|
return u.detailPanelContent(gtx)
|
|
})
|
|
}
|
|
|
|
func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|
panel := layout.Flex{Axis: layout.Vertical}
|
|
_ = panel
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
|
if u.isVaultLocked() {
|
|
return []layout.FlexChild{
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(14), "Enter the master password, choose a key file, or provide both.")
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
|
layout.Rigid(u.unlockPanel),
|
|
}
|
|
}
|
|
item, ok := u.selectedEntry()
|
|
if !ok && !u.editingEntry {
|
|
return []layout.FlexChild{
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(18), "Entry details")
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage())
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
}
|
|
}
|
|
if u.editingEntry {
|
|
rows := []layout.Widget{
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
title := "New Entry"
|
|
if ok {
|
|
title = "Edit Entry"
|
|
}
|
|
lbl := material.Label(u.theme, unit.Sp(18), title)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
},
|
|
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
|
u.entryEditorPanel,
|
|
}
|
|
return []layout.FlexChild{
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
|
return rows[i](gtx)
|
|
})
|
|
}),
|
|
}
|
|
}
|
|
password := u.detailPasswordValue()
|
|
titleSize := unit.Sp(26)
|
|
titlePad := unit.Dp(10)
|
|
sectionGap := unit.Dp(8)
|
|
if u.mode == "phone" {
|
|
titleSize = unit.Sp(18)
|
|
titlePad = unit.Dp(6)
|
|
sectionGap = unit.Dp(6)
|
|
}
|
|
rows := []layout.Widget{
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, titleSize, item.Title)
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
},
|
|
layout.Spacer{Height: titlePad}.Layout,
|
|
detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / ")),
|
|
layout.Spacer{Height: sectionGap}.Layout,
|
|
detailLine(u.theme, "Username", item.Username),
|
|
layout.Spacer{Height: sectionGap}.Layout,
|
|
u.passwordLine("Password", password),
|
|
layout.Spacer{Height: sectionGap}.Layout,
|
|
detailLine(u.theme, "URL", item.URL),
|
|
layout.Spacer{Height: sectionGap}.Layout,
|
|
detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")),
|
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
if u.mode == "phone" {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.copyUser, "Copy User")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL")
|
|
}),
|
|
)
|
|
}
|
|
return layout.Flex{}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.copyUser, "Copy User")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.copyPass, "Copy Password")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.Button(u.theme, &u.copyURL, "Copy URL")
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
},
|
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Body1(u.theme, item.Notes)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
},
|
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
|
u.historyPanel,
|
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
|
func(gtx layout.Context) layout.Dimensions {
|
|
switch u.state.Section {
|
|
case appstate.SectionTemplates:
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.editEntry, "Edit Template")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template")
|
|
}),
|
|
)
|
|
case appstate.SectionRecycleBin:
|
|
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry")
|
|
default:
|
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.editEntry, "Edit")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete")
|
|
}),
|
|
)
|
|
}
|
|
},
|
|
}
|
|
return []layout.FlexChild{
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
|
return rows[i](gtx)
|
|
})
|
|
}),
|
|
}
|
|
}()...)
|
|
}
|
|
|
|
func (u *ui) banner(gtx layout.Context) layout.Dimensions {
|
|
banner := u.bannerSurface()
|
|
if banner.Kind == bannerNone {
|
|
return layout.Dimensions{}
|
|
}
|
|
|
|
bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255}
|
|
fg := accentColor
|
|
switch banner.Kind {
|
|
case bannerLoading:
|
|
bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255}
|
|
fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255}
|
|
case bannerError:
|
|
bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255}
|
|
fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255}
|
|
}
|
|
|
|
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(14), banner.Message)
|
|
lbl.Color = fg
|
|
return lbl.Layout(gtx)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions {
|
|
history := u.visibleHistory()
|
|
u.ensureHistoryClickables()
|
|
|
|
children := []layout.FlexChild{
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(14), "History")
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
}
|
|
|
|
if len(history) == 0 {
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.")
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}))
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
}
|
|
|
|
for i := range history {
|
|
index := i
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return u.historyRow(gtx, &u.historyClicks[index], index, history[index])
|
|
}))
|
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
}
|
|
|
|
if selected, ok := u.selectedHistoryEntry(); ok {
|
|
children = append(children,
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), "Selected Version")
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(detailLine(u.theme, "Username", selected.Username)),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(detailLine(u.theme, "URL", selected.URL)),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Body2(u.theme, selected.Notes)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
}
|
|
|
|
func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions {
|
|
for click.Clicked(gtx) {
|
|
_ = u.selectHistoryVersion(index)
|
|
}
|
|
|
|
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
row := func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(13), fmt.Sprintf("Version %d", index))
|
|
lbl.Color = accentColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), item.Username)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), item.URL)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Body2(u.theme, item.Notes)
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
if index == u.selectedHistoryIndex {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
size := gtx.Constraints.Min
|
|
if size.X == 0 {
|
|
size.X = gtx.Constraints.Max.X
|
|
}
|
|
if size.Y == 0 {
|
|
size.Y = gtx.Constraints.Max.Y
|
|
}
|
|
paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op())
|
|
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
|
|
return layout.Dimensions{Size: size}
|
|
}),
|
|
layout.Stacked(row),
|
|
)
|
|
}
|
|
|
|
return layout.Background{}.Layout(gtx, fill(panelColor), row)
|
|
})
|
|
}
|
|
|
|
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
|
|
if u.state.Section == appstate.SectionRecycleBin {
|
|
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin")
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}
|
|
|
|
u.syncCurrentPath()
|
|
displayPath := u.displayPath()
|
|
crumbs := append([]string{"/"}, append([]string{}, displayPath...)...)
|
|
if u.state.Section == appstate.SectionTemplates {
|
|
crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...)
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild {
|
|
children := make([]layout.FlexChild, 0, len(crumbs)*2)
|
|
for i, name := range crumbs {
|
|
index := i
|
|
label := name
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
for u.breadcrumbs[index].Clicked(gtx) {
|
|
if index == 0 {
|
|
root := u.hiddenVaultRoot()
|
|
if root == "" {
|
|
u.setCurrentPath(nil)
|
|
} else {
|
|
u.setCurrentPath([]string{root})
|
|
}
|
|
} else {
|
|
nextPath := crumbs[1 : index+1]
|
|
root := u.hiddenVaultRoot()
|
|
if root != "" {
|
|
nextPath = append([]string{root}, nextPath...)
|
|
}
|
|
u.setCurrentPath(nextPath)
|
|
}
|
|
u.filter()
|
|
}
|
|
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
|
|
btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index)))
|
|
btn.TextSize = unit.Sp(11)
|
|
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
|
|
return btn.Layout(gtx)
|
|
}))
|
|
if i < len(crumbs)-1 {
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), "/")
|
|
lbl.Color = mutedColor
|
|
return layout.UniformInset(unit.Dp(6)).Layout(gtx, lbl.Layout)
|
|
}))
|
|
}
|
|
}
|
|
return children
|
|
}()...)
|
|
}
|
|
|
|
func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
|
|
groups := append([]string{}, u.childGroups()...)
|
|
if len(u.groupClicks) < len(groups) {
|
|
u.groupClicks = make([]widget.Clickable, len(groups))
|
|
}
|
|
if len(groups) == 0 {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
|
children := []layout.FlexChild{
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), "Groups")
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
}
|
|
for i, group := range groups {
|
|
idx := i
|
|
name := group
|
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
for u.groupClicks[idx].Clicked(gtx) {
|
|
u.state.EnterGroup(name)
|
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
|
u.filter()
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
|
|
}))
|
|
if i < len(groups)-1 {
|
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
}
|
|
}
|
|
return children
|
|
}()...)
|
|
}
|
|
|
|
func detailLine(th *material.Theme, label, value string) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(th, unit.Sp(16), value)
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (u *ui) passwordLine(label, value string) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
icon := u.eyeIcon
|
|
desc := "Show password"
|
|
if u.showPassword {
|
|
icon = u.eyeOffIcon
|
|
desc = "Hide password"
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label))
|
|
lbl.Color = mutedColor
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(16), value)
|
|
return lbl.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
btn := material.IconButton(u.theme, &u.togglePasswordInline, icon, desc)
|
|
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
|
|
btn.Color = accentColor
|
|
btn.Size = unit.Dp(18)
|
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
return btn.Layout(gtx)
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (u *ui) detailPasswordValue() string {
|
|
item, ok := u.selectedEntry()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if u.showPassword {
|
|
return item.Password
|
|
}
|
|
return strings.Repeat("•", max(8, len(item.Password)))
|
|
}
|
|
|
|
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, w)
|
|
})
|
|
}
|
|
|
|
func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
|
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, w)
|
|
})
|
|
}
|
|
|
|
func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions {
|
|
appearance := fieldFocusAppearance(gtx.Metric, focused)
|
|
size := gtx.Constraints.Min
|
|
if size.X == 0 {
|
|
size.X = gtx.Constraints.Max.X
|
|
}
|
|
if size.Y == 0 {
|
|
size.Y = appearance.MinHeight
|
|
}
|
|
gtx.Constraints.Min = size
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op())
|
|
return layout.Dimensions{Size: size}
|
|
}),
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
return drawFocusOutline(gtx, appearance, size)
|
|
}),
|
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
|
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
|
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
|
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
|
return layout.Dimensions{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
|
min := gtx.Constraints.Min
|
|
gtx.Constraints.Min = image.Point{}
|
|
dims := w(gtx)
|
|
if dims.Size.X < min.X {
|
|
dims.Size.X = min.X
|
|
}
|
|
if dims.Size.Y < min.Y {
|
|
dims.Size.Y = min.Y
|
|
}
|
|
if dims.Size.Y < appearance.MinHeight {
|
|
dims.Size.Y = appearance.MinHeight
|
|
}
|
|
return dims
|
|
}),
|
|
)
|
|
}
|
|
|
|
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
|
|
btn := material.Button(th, click, label)
|
|
btn.Background, btn.Color = buttonFocusColors(false)
|
|
btn.CornerRadius = unit.Dp(10)
|
|
btn.TextSize = unit.Sp(15)
|
|
return btn.Layout(gtx)
|
|
}
|
|
|
|
func fill(c color.NRGBA) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op())
|
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
|
}
|
|
}
|
|
|
|
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")
|
|
flag.Parse()
|
|
|
|
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", "desktop")
|
|
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
|
|
|
width := unit.Dp(1180)
|
|
height := unit.Dp(760)
|
|
if strings.EqualFold(resolvedMode, "phone") {
|
|
// Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview.
|
|
width = unit.Dp(412)
|
|
height = unit.Dp(915)
|
|
}
|
|
|
|
go func() {
|
|
w := new(app.Window)
|
|
w.Option(
|
|
app.Title(productName),
|
|
app.Size(width, height),
|
|
)
|
|
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir)); err != nil {
|
|
panic(err)
|
|
}
|
|
os.Exit(0)
|
|
}()
|
|
app.Main()
|
|
}
|
|
|
|
func run(w *app.Window, mode string, paths statePaths) error {
|
|
var ops op.Ops
|
|
ui := newUI(mode, paths)
|
|
for {
|
|
e := w.Event()
|
|
switch e := e.(type) {
|
|
case app.DestroyEvent:
|
|
return e.Err
|
|
case app.FrameEvent:
|
|
gtx := app.NewContext(&ops, e)
|
|
ui.layout(gtx)
|
|
e.Frame(gtx.Ops)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 strings.TrimSpace(string(output)), nil
|
|
}
|