Files
keepassgo/main.go
T
2026-03-29 11:23:54 -07:00

1174 lines
36 KiB
Go

package main
import (
"flag"
"fmt"
"image"
"image/color"
"os"
"strings"
"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/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type entry = vault.Entry
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
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
openURL 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
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
removeAttachment widget.Clickable
exportAttachment widget.Clickable
restoreHistory widget.Clickable
generatePassword widget.Clickable
createGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
masterKeyPasswordOnly widget.Clickable
masterKeyKeyFileOnly widget.Clickable
masterKeyComposite widget.Clickable
entryClicks []widget.Clickable
breadcrumbs []widget.Clickable
groupClicks []widget.Clickable
state appstate.State
masterKeyMode vault.MasterKeyMode
visible []entry
currentPath []string
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
statusMessage string
errorMessage string
}
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}
)
func newUI(mode string) *ui {
return newUIWithSession(mode, &session.Manager{})
}
func newUIWithModel(mode string, model vault.Model) *ui {
return newUIWithState(mode, &uiSession{model: model})
}
func newUIWithSession(mode string, sess appstate.CurrentSession) *ui {
return newUIWithState(mode, sess)
}
func newUIWithState(mode string, sess appstate.CurrentSession) *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{},
masterKeyMode: vault.MasterKeyModePasswordOnly,
}
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.passwordProfile.SetText("strong")
u.filter()
return u
}
func (u *ui) filter() {
u.state.SearchQuery = u.search.Text()
u.state.CurrentPath = append([]string(nil), u.currentPath...)
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 (u *ui) showEntriesSection() {
u.state.Section = appstate.SectionEntries
u.currentPath = nil
u.filter()
}
func (u *ui) showTemplatesSection() {
u.state.Section = appstate.SectionTemplates
u.currentPath = nil
u.filter()
}
func (u *ui) showRecycleBinSection() {
u.state.Section = appstate.SectionRecycleBin
u.currentPath = nil
u.filter()
}
func (u *ui) childGroups() []string {
u.state.SearchQuery = u.search.Text()
u.state.CurrentPath = append([]string(nil), u.currentPath...)
groups, err := u.state.ChildGroups()
if err != nil {
return nil
}
return groups
}
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) 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) currentMasterKey() (vault.MasterKey, error) {
password := u.masterPassword.Text()
path := strings.TrimSpace(u.keyFilePath.Text())
switch u.masterKeyMode {
case vault.MasterKeyModeKeyFileOnly:
if path == "" {
return vault.MasterKey{}, fmt.Errorf("key file is required")
}
case vault.MasterKeyModePasswordAndKeyFile:
if password == "" {
return vault.MasterKey{}, fmt.Errorf("master password is required")
}
if path == "" {
return vault.MasterKey{}, fmt.Errorf("key file is required")
}
default:
if password == "" {
return vault.MasterKey{}, fmt.Errorf("master password is required")
}
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(mode vault.MasterKeyMode) {
u.masterKeyMode = mode
}
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
if err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
u.currentPath = nil
u.filter()
return nil
}
func (u *ui) openVaultAction() error {
key, err := u.currentMasterKey()
if err != nil {
return err
}
if err := u.state.OpenVault(strings.TrimSpace(u.vaultPath.Text()), key); err != nil {
return err
}
u.currentPath = nil
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 {
if err := u.state.SaveAs(strings.TrimSpace(u.saveAsPath.Text())); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
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.currentPath = nil
u.filter()
return nil
}
func (u *ui) lockAction() error {
if err := u.state.Lock(); err != nil {
return err
}
u.showPassword = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) runAction(label string, action func() error) {
if err := action(); err != nil {
u.errorMessage = err.Error()
u.statusMessage = ""
return
}
u.errorMessage = ""
u.statusMessage = label + " complete"
}
func (u *ui) ensureNavClickables() {
if len(u.breadcrumbs) < len(u.currentPath)+1 {
u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1)
}
}
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.unlockVault.Clicked(gtx) {
u.runAction("unlock vault", u.unlockAction)
}
for u.masterKeyPasswordOnly.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
}
for u.masterKeyKeyFileOnly.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
}
for u.masterKeyComposite.Clicked(gtx) {
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
}
for u.showEntries.Clicked(gtx) {
u.showEntriesSection()
}
for u.showTemplates.Clicked(gtx) {
u.showTemplatesSection()
}
for u.showRecycle.Clicked(gtx) {
u.showRecycleBinSection()
}
for u.lockVault.Clicked(gtx) {
u.runAction("lock vault", u.lockAction)
}
for u.addEntry.Clicked(gtx) {
u.state.SelectedEntryID = ""
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.statusMessage = "new entry form ready"
u.errorMessage = ""
}
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.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.runAction("create group", u.createGroupAction)
}
for u.renameGroup.Clicked(gtx) {
u.runAction("rename group", u.renameGroupAction)
}
for u.deleteGroup.Clicked(gtx) {
u.runAction("delete group", u.deleteCurrentGroupAction)
}
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(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
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),
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), "Vault")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
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(8)}.Layout),
layout.Rigid(u.lifecycleControls),
)
})
}
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 {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(24), "Vault")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "A single app concept for desktop and Android")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}),
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(10)}.Layout),
layout.Rigid(u.lifecycleControls),
)
})
}
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(u.sectionBar),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(u.pathBar),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(u.groupBar),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(u.groupControls),
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 outlinedField(gtx, 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 {
label := "Add Entry"
if u.mode == "phone" {
label = "+ Add Entry"
}
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), "No entries match.")
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) 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.showTemplates, "Templates")
}),
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 {
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(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 {
item, ok := u.selectedEntry()
if !ok {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Edit the current form to create or update an item")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(u.entryEditorPanel),
)
}
password := strings.Repeat("•", max(8, len(item.Password)))
if u.showPassword {
password = item.Password
}
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(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.openURL, "Open 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.openURL, "Open 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.entryEditorPanel,
}
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) 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)
}
crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...)
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 {
u.currentPath = nil
} else {
u.currentPath = append([]string{}, crumbs[1:index+1]...)
}
u.filter()
}
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.TextSize = unit.Sp(12)
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10}
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{Spacing: layout.SpaceStart}.Layout(gtx, func() []layout.FlexChild {
children := make([]layout.FlexChild, 0, len(groups))
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.currentPath = append(append([]string{}, u.currentPath...), name)
u.filter()
}
btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name)
btn.Background = color.NRGBA{R: 241, G: 236, B: 227, A: 255}
btn.Color = accentColor
btn.TextSize = unit.Sp(12)
return btn.Layout(gtx)
}))
}
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 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 outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
border := color.NRGBA{R: 202, G: 194, B: 180, A: 255}
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Dp(unit.Dp(44))
}
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 {
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
paint.FillShape(gtx.Ops, border, 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 < gtx.Dp(unit.Dp(44)) {
dims.Size.Y = gtx.Dp(unit.Dp(44))
}
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 = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
btn.Color = accentColor
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", "desktop", "window mode: desktop or phone")
flag.Parse()
width := unit.Dp(1180)
height := unit.Dp(760)
if strings.EqualFold(*mode, "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("Vault Mock"),
app.Size(width, height),
)
if err := run(w, strings.ToLower(*mode)); err != nil {
panic(err)
}
os.Exit(0)
}()
app.Main()
}
func run(w *app.Window, mode string) error {
var ops op.Ops
ui := newUI(mode)
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) 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
}