From e108a9b7a7c527ce92bf23c5ddf829b4fd59f219 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 6 Sep 2019 10:45:18 -0400 Subject: [PATCH] Add configuration file and UI, and animated overlay when passwords are copied to the clipboard. --- .gitignore | 4 +- cmd/passgo-gui/impl_darwin.go | 61 +++++++ cmd/passgo-gui/main.go | 329 +++++++++++++++++++++++++++++----- cmd/passgo/main.go | 3 +- main.go | 17 +- 5 files changed, 358 insertions(+), 56 deletions(-) create mode 100644 cmd/passgo-gui/impl_darwin.go diff --git a/.gitignore b/.gitignore index a723c84..f965e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -cmd/gpass/passgo -cmd/gpass-gui/passgo-gui +cmd/passgo/passgo +cmd/passgo-gui/passgo-gui diff --git a/cmd/passgo-gui/impl_darwin.go b/cmd/passgo-gui/impl_darwin.go new file mode 100644 index 0000000..62edead --- /dev/null +++ b/cmd/passgo-gui/impl_darwin.go @@ -0,0 +1,61 @@ +//+build !android !linux + +package main + +import ( + "os" + "os/user" + "path" + + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/sfnt" +) + +var ( + regular *sfnt.Font + confDir string +) + +func setFont() error { + f, err := os.Open("/System/Library/Fonts/AppleSDGothicNeo.ttc") + if err != nil { + log(Info,"Cannot open system font.") + return err + + } + collection, err := sfnt.ParseCollectionReaderAt(f) + if err != nil { + log(Info,"Cannot parse system font.") + return err + } + regular, err = collection.Font(0) + if err != nil { + log(Info,"Cannot access first font in collection.") + return err + } + return nil +} + +func init() { + err := setFont() + if err != nil { + regular, err = sfnt.Parse(goregular.TTF) + if err != nil { + log(Fatal,"Cannot parse default font: ", err) + } + } + usr, err := user.Current() + if err != nil { + log(Fatal, "Cannot get current user: ", err) + } + confDir = path.Join(usr.HomeDir, ".config/passgo") + if _, err := os.Stat(confDir); os.IsNotExist(err) { + err = os.MkdirAll(confDir, 0700) + if err != nil { + log(Info, "Cannot create configuration directory ",confDir) + log(Fatal, err) + } else { + log(Info, "Configuration directory created") + } + } +} diff --git a/cmd/passgo-gui/main.go b/cmd/passgo-gui/main.go index b16d13b..4032b47 100644 --- a/cmd/passgo-gui/main.go +++ b/cmd/passgo-gui/main.go @@ -3,12 +3,14 @@ package main import ( - //"fmt" "image" "image/color" + "io/ioutil" + "os" "path" "strings" "sync" + "time" "gioui.org/ui" "gioui.org/ui/app" @@ -23,19 +25,47 @@ import ( "gioui.org/ui/text" "github.com/fsnotify/fsnotify" - "golang.org/x/image/font/gofont/goregular" - "golang.org/x/image/font/sfnt" + "gopkg.in/yaml.v2" "git.wow.st/gmp/passgo" ) +type conf struct { + StoreDir string +} + func main() { - var err error - store, err = passgo.GetStore() + var fd *os.File + confFile := path.Join(confDir, "config.yml") + if _, err := os.Stat(confFile); os.IsNotExist(err) { + fd, err = os.Create(confFile) + if err != nil { + log(Fatal, "Cannot create configuration file: ", err) + } + } + + confbytes, err := ioutil.ReadFile(confFile) + if err != nil { + log(Fatal, "Cannot read configuration file: ", err) + } + if err = yaml.UnmarshalStrict(confbytes, &Config); err != nil { + log(Fatal, "Cannot parse configuration file: ", err) + } + store.Dir = Config.StoreDir + log(Info," StoreDir = ", store.Dir) + err = passgo.GetStore(&store) if err != nil { log(Fatal, err) } + + if fd != nil { // we still have an empty conf file open: + Config.StoreDir = store.Dir + saveConf(fd) + } + + reload = make(chan struct{}) updated = make(chan struct{}) + go Updater() log(Info, "Staring event loop") go eventLoop() @@ -44,17 +74,23 @@ func main() { } var ( + Config conf l []passgo.Pass mux sync.Mutex - store *passgo.Store + store passgo.Store + reload chan struct{} updated chan struct{} + black = color.RGBA{A: 0xff, R:0, G:0, B:0} + white = color.RGBA{A: 0xff, R:0xff, G:0xff, B:0xff} + gray = color.RGBA{A: 0xff, R:0xf0, G:0xf0, B:0xf0} + darkgray = color.RGBA{A: 0xff, R:0xa0, G:0xa0, B:0xa0} ) func Updater() { update := func() { ltmp, err := store.List() if err != nil { - log(Fatal, err) + log(Info, err) } mux.Lock() l = ltmp @@ -70,6 +106,8 @@ func Updater() { watcher.Add(store.Dir) for { select { + case <-reload: + update() case <-watcher.Events: update() case e := <-watcher.Errors: @@ -78,11 +116,70 @@ func Updater() { } } +func saveConf(fds ...*os.File) { + var fd *os.File + var err error + if len(fds) > 0 && fds[0] != nil { + fd = fds[0] + } else { + fd, err = os.Create(path.Join(confDir, "config.yml")) + if err != nil { + log(Fatal, "Cannot open config file: ", err) + } + } + + confbytes, err := yaml.Marshal(Config) + if err != nil { + log(Fatal, "Cannot save configuration: ", err) + } + _, err = fd.Write(confbytes) + if err != nil { + log(Fatal, "Cannot write to configuration: ", err) + } +} + +type Overlay struct { + Face text.Face + Text string + Click gesture.Click + Color color.RGBA + Background color.RGBA + Alignment text.Alignment +} + +func (b *Overlay) Layout(c ui.Config, ops *ui.Ops, cs layout.Constraints) layout.Dimensions { + ins := layout.UniformInset(ui.Dp(1)) + cs = ins.Begin(c, ops, cs) + var dims layout.Dimensions + st := layout.Stack{} + st.Init(ops, cs) + { + cs = st.Rigid() + l := text.Label{ + Face: b.Face, + Text: b.Text, + Alignment: b.Alignment, + } + ins := layout.UniformInset(ui.Dp(4)) + l.Material.Record(ops) + paint.ColorOp{Color: b.Color}.Add(ops) + l.Material.Stop() + dims = ins.End(l.Layout(ops, ins.Begin(c, ops, cs))) + pointer.RectAreaOp{image.Rect(0, 0, dims.Size.X, dims.Size.Y)}.Add(ops) + } + c2 := st.End(dims) + c1 := st.End(layoutRRect(b.Background, c, ops, st.Expand())) + dims = st.Layout(c1, c2) + return ins.End(dims) +} + type Button struct { Face text.Face Label string Click gesture.Click Color color.RGBA + Background color.RGBA + Alignment text.Alignment clicked bool } @@ -113,7 +210,7 @@ func rrect(ops *ui.Ops, width, height, se, sw, nw, ne float32) { b.End() } -func (b *Button) Layout(c ui.Config, ops *ui.Ops, q input.Queue, cs layout.Constraints) layout.Dimensions { +func (b *Button) Layout(c ui.Config, q input.Queue, ops *ui.Ops, cs layout.Constraints) layout.Dimensions { b.clicked = false for ev, ok := b.Click.Next(q); ok; ev, ok = b.Click.Next(q) { if ev.Type == gesture.TypeClick { @@ -130,14 +227,16 @@ func (b *Button) Layout(c ui.Config, ops *ui.Ops, q input.Queue, cs layout.Const l := text.Label{ Face: b.Face, Text: b.Label, + Alignment: b.Alignment, } ins := layout.UniformInset(ui.Dp(4)) + paint.ColorOp{Color: b.Color}.Add(ops) dims = ins.End(l.Layout(ops, ins.Begin(c, ops, cs))) pointer.RectAreaOp{image.Rect(0, 0, dims.Size.X, dims.Size.Y)}.Add(ops) b.Click.Add(ops) } c2 := st.End(dims) - c1 := st.End(layoutRRect(b.Color, c, ops, st.Expand())) + c1 := st.End(layoutRRect(b.Background, c, ops, st.Expand())) dims = st.Layout(c1, c2) return ins.End(dims) } @@ -149,24 +248,44 @@ func (b *Button) Clicked() bool { func eventLoop() { w := app.NewWindow(app.WithWidth(ui.Dp(250))) q := w.Queue() - _ = q + var c ui.Config ops := new(ui.Ops) + var dims layout.Dimensions + var cs layout.Constraints + var margincs layout.Constraints var faces measure.Faces - regular, err := sfnt.Parse(goregular.TTF) - if err != nil { - log(Fatal, "Cannot parse font.") - } + var c1 layout.FlexChild // flex child for title bar face := faces.For(regular, ui.Sp(16)) + margin := layout.UniformInset(ui.Dp(10)) + + title := &text.Label{Face: face, Text:"passgo"} + dotsbtn := &Button{ + Face: face, + Label: "\xe2\x8b\xae", + Alignment: text.End, + Color: black, + Background: gray, + } + + titleflex := &layout.Flex{Axis: layout.Horizontal} + + flex := &layout.Flex{Axis: layout.Vertical} passInput := &text.Editor{Face: face, SingleLine: true} passInput.SetText("passphrase") - passSubmit := &Button{Face: face, Label: "submit"} + passSubmit := &Button{Face: face, Label: "submit", Color: black} lst := &layout.List{Axis: layout.Vertical} passBtns := make([]*Button, 0) _ = passInput _ = passSubmit pathnames := make([]string, 0) + copied := &Overlay{Face: face, Text: "copied to clipboard", + Color: black, + Background: darkgray, + Alignment: text.Middle, + } + var copiedWhen time.Time updateBtns := func() { passBtns = passBtns[:0] @@ -182,7 +301,7 @@ func eventLoop() { passBtns = append(passBtns, &Button{ Face: face, Label: strings.Join([]string{s, n, z}, ""), - Color: color.RGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0}, + Background: gray, }) pathnames = append(pathnames, x.Pathname) } @@ -190,50 +309,170 @@ func eventLoop() { } updateBtns() + confBtn := &Button{ + Face: face, + Label: "configure", + Alignment: text.Middle, + Color: black, + Background: gray, + } + + storeDirLabel := &text.Label{Face: face, Text: "Store directory"} + storeDirEd := &text.Editor{Face: face, SingleLine: true} + storeDirEd.SetText(store.Dir) + saveBtn := &Button{ + Face: face, + Label: "save", + Alignment: text.End, + Color: black, + Background: gray, + } + + anim := &time.Ticker{} + animating := false + animOn := func() { + anim = time.NewTicker(time.Second / 120) + animating = true + w.Invalidate() + } + animOff := func() { + anim.Stop() + animating = false + } + + var listPage, confPage, page func() + _ = confPage + + listPage = func() { + cs = flex.Flexible(1.0) + mux.Lock() + if lst.Dragging() { + key.HideInputOp{}.Add(ops) + } + var c2 layout.FlexChild + if len(passBtns) == 0 { + c2 = flex.End(confBtn.Layout(c, q, ops, cs)) + if confBtn.Clicked() { + log(Info,"Configure") + w.Invalidate() + page = confPage + } + } else { + for lst.Init(c, q, ops, cs, len(passBtns)); lst.More(); lst.Next() { + btn := passBtns[lst.Index()] + dims = btn.Layout(c, q, ops, lst.Constraints()) + lst.End(dims) + if btn.Clicked() { + log(Info, "Clicked ", btn.Label) + // don't block UI thread on decryption attempt + go func(name string) { + //p,err := store.Decrypt(name, prompt) + p, err := store.Decrypt(name) + if err == nil { + passgo.Clip(p) + copiedWhen = time.Now() + animOn() + } else { + log(Info, "Can't decrypt ", name) + log(Info, err) + } + }(pathnames[lst.Index()]) + } + } + c2 = flex.End(lst.Layout()) + } + mux.Unlock() + if dotsbtn.Clicked() { + log(Info, "Configure") + w.Invalidate() + page = confPage + } + flex.Layout(c1, c2) + x := time.Since(copiedWhen).Seconds() + start, end := 1.5, 1.75 + switch { + case x > start && x < end: + fade := (end - x) / (end - start) + copied.Color.R = uint8(float64(black.R) * fade) + copied.Color.G = uint8(float64(black.G) * fade) + copied.Color.B = uint8(float64(black.B) * fade) + copied.Color.A = uint8(float64(black.A) * fade) + copied.Background.R = uint8(float64(darkgray.R) * fade) + copied.Background.G = uint8(float64(darkgray.G) * fade) + copied.Background.B = uint8(float64(darkgray.B) * fade) + copied.Background.A = uint8(float64(darkgray.A) * fade) + fallthrough + case x <= start: + cs = margincs + al := layout.Align{Alignment: layout.SE} + cs = al.Begin(ops, cs) + cs.Width.Min = cs.Width.Max + dims = al.End(copied.Layout(c, ops, cs)) + case animating: + copied.Color = black + copied.Background = darkgray + animOff() + } + } + + confPage = func() { + cs = flex.Rigid() + c2 := flex.End(storeDirLabel.Layout(ops, cs)) + cs = flex.Rigid() + c3 := flex.End(storeDirEd.Layout(c, q, ops, cs)) + cs = flex.Rigid() + al := &layout.Align{Alignment: layout.E} + cs = al.Begin(ops, cs) + dims = saveBtn.Layout(c, q, ops, cs) + c4 := flex.End(al.End(dims)) + flex.Layout(c1, c2, c3, c4) + if saveBtn.Clicked() { + log(Info, "Save") + go func() { // do not block UI thread + store.Dir = storeDirEd.Text() + passgo.GetStore(&store) + Config.StoreDir = store.Dir + saveConf() + reload <-struct{}{} + }() + page = listPage + } + } + + page = listPage + for { select { case <-updated: log(Info, "UPDATE") updateBtns() w.Invalidate() + case <-anim.C: + w.Invalidate() case e := <-w.Events(): switch e := e.(type) { case app.DestroyEvent: return case app.UpdateEvent: - c := &e.Config + c = &e.Config ops.Reset() faces.Reset(c) - var dims layout.Dimensions - cs := layout.RigidConstraints(e.Size) + cs = layout.RigidConstraints(e.Size) cs = margin.Begin(c, ops, cs) + margincs = cs + flex.Init(ops, cs) + cs = flex.Rigid() + titleflex.Init(ops, cs) + cs = titleflex.Rigid() + ct2 := titleflex.End(dotsbtn.Layout(c, q, ops, cs)) + cs = titleflex.Flexible(1.0) + cs.Width.Min = cs.Width.Max + ct1 := titleflex.End(title.Layout(ops, cs)) + c1 = flex.End(titleflex.Layout(ct1, ct2)) - mux.Lock() - if lst.Dragging() { - key.HideInputOp{}.Add(ops) - } - for lst.Init(c, q, ops, cs, len(passBtns)); lst.More(); lst.Next() { - btn := passBtns[lst.Index()] - dims = btn.Layout(c, ops, q, lst.Constraints()) - lst.End(dims) - if btn.Clicked() { - // don't block UI thread on decryption attempt - log(Info, "Clicked ", btn.Label) - go func(name string) { - //p,err := store.Decrypt(name, prompt) - p, err := store.Decrypt(name) - if err == nil { - passgo.Clip(p) - } else { - log(Info, "Can't decrypt ", name) - log(Info, err) - } - }(pathnames[lst.Index()]) - } - } - mux.Unlock() - dims = lst.Layout() - dims = margin.End(dims) + page() + + margin.End(dims) w.Update(ops) } } diff --git a/cmd/passgo/main.go b/cmd/passgo/main.go index 066e003..3f60e23 100644 --- a/cmd/passgo/main.go +++ b/cmd/passgo/main.go @@ -35,7 +35,8 @@ func parse(args []string) ([]string, options) { } func main() { - store, err := passgo.GetStore() + var store passgo.Store + err := passgo.GetStore(&store) if err != nil { fmt.Println(err) os.Exit(-1) diff --git a/main.go b/main.go index 660c266..7afaba4 100644 --- a/main.go +++ b/main.go @@ -34,25 +34,26 @@ type Store struct { keyring openpgp.KeyRing } -func GetStore() (*Store, error) { - ret := &Store{} +func GetStore(store *Store) error { u, err := user.Current() if err != nil { - return ret, fmt.Errorf("Can't get current user.") + return fmt.Errorf("Can't get current user.") + } + if store.Dir == "" { + store.Dir = path.Join(u.HomeDir, ".password-store") } - ret.Dir = path.Join(u.HomeDir, ".password-store") fd, err := os.Open(path.Join(u.HomeDir, ".gnupg/secring.gpg")) defer fd.Close() if err != nil { - return ret, fmt.Errorf("Can't open keyring file") + return fmt.Errorf("Can't open keyring file") } kr, err := openpgp.ReadKeyRing(fd) if err != nil { - return ret, fmt.Errorf("Can't open gnupg keyring.") + return fmt.Errorf("Can't open gnupg keyring.") } - ret.keyring = kr - return ret, nil + store.keyring = kr + return nil } type caseInsensitive []os.FileInfo