fyne-io systray attempt; crude first restic wrapper

This commit is contained in:
Wouter Groeneveld 2023-03-04 20:54:10 +01:00
parent 0a4c497fda
commit 3a8e84414e
6 changed files with 322 additions and 130 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea/
on_exit_*.txt
restictray

18
go.mod
View File

@ -3,18 +3,14 @@ module brainbaking.com/restictray
go 1.19
require (
github.com/getlantern/systray v1.2.1
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
fyne.io/systray v1.10.0
github.com/rs/zerolog v1.29.0
)
require (
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 // indirect
github.com/godbus/dbus/v5 v5.0.4 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/tevino/abool v1.2.0 // indirect
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect
)

46
go.sum
View File

@ -1,29 +1,19 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI=
github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
fyne.io/systray v1.10.0 h1:Yr1D9Lxeiw3+vSuZWPlaHC8BMjIHZXJKkek706AfYQk=
fyne.io/systray v1.10.0/go.mod h1:oM2AQqGJ1AMo4nNqZFYU8xYygSBZkW2hmdJ7n4yjedE=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA=
github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

205
main.go
View File

@ -1,107 +1,130 @@
package main
import (
"fmt"
"io/ioutil"
"brainbaking.com/restictray/restic"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"os"
"time"
"github.com/getlantern/systray"
"github.com/getlantern/systray/example/icon"
"github.com/skratchdot/open-golang/open"
"fyne.io/systray"
"fyne.io/systray/example/icon"
)
// I'm ignoring go threading issues here; assume no clicks happen at the same time.
var wrapper *restic.Wrapper
func main() {
onExit := func() {
now := time.Now()
ioutil.WriteFile(fmt.Sprintf(`on_exit_%d.txt`, now.UnixNano()), []byte(now.String()), 0644)
// init and setup logging
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
wrapper = &restic.Wrapper{}
// bootstrap systray (this is a blocking call, second func is onExit)
systray.Run(onSystrayReady, func() {})
}
func addMenuLatestSnapshot() {
snapshot := wrapper.LastSnapshot()
systray.AddMenuItem("Latest: "+snapshot.Id+" @ "+snapshot.ShortTime(), "Latest Restic snapshot")
}
func addMenuNextTime(cnf *restic.Config) {
nextTime := wrapper.LastSnapshot().Time.Add(time.Duration(cnf.BackupTimeInHours) * time.Hour)
msg := "Next @ " + nextTime.Format(restic.ShortTimeFormat)
if time.Now().After(nextTime) {
msg = "⚠️ Overdue - " + msg
}
systray.Run(onReady, onExit)
systray.AddMenuItem(msg, "Future Restic snapshot")
}
func onReady() {
systray.SetTemplateIcon(icon.Data, icon.Data)
systray.SetTitle("Awesome App")
systray.SetTooltip("Lantern")
mQuitOrig := systray.AddMenuItem("Quit", "Quit the whole app")
func addMenuQuit() {
systray.AddSeparator()
addMenuWithQuitAction("Quit", "Quit Restictray")
}
func addMenuError(err error) {
addMenuWithQuitAction("❗ "+err.Error(), "Restictray Error")
}
func handleError(err error) {
log.Err(err).Msg("")
systray.ResetMenu()
addMenuError(err)
addMenuQuit()
}
func addMenuWithQuitAction(title string, tooltip string) {
mQuit := systray.AddMenuItem(title, tooltip)
go func() {
<-mQuitOrig.ClickedCh
fmt.Println("Requesting quit")
<-mQuit.ClickedCh
log.Info().Msg("Requesting quit")
systray.Quit()
fmt.Println("Finished quitting")
}()
// We can manipulate the systray in other goroutines
go func() {
systray.SetTemplateIcon(icon.Data, icon.Data)
systray.SetTitle("Awesome App")
systray.SetTooltip("Pretty awesome棒棒嗒")
mChange := systray.AddMenuItem("Change Me", "Change Me")
mChecked := systray.AddMenuItemCheckbox("Unchecked", "Check Me", true)
mEnabled := systray.AddMenuItem("Enabled", "Enabled")
// Sets the icon of a menu item. Only available on Mac.
mEnabled.SetTemplateIcon(icon.Data, icon.Data)
systray.AddMenuItem("Ignored", "Ignored")
subMenuTop := systray.AddMenuItem("SubMenuTop", "SubMenu Test (top)")
subMenuMiddle := subMenuTop.AddSubMenuItem("SubMenuMiddle", "SubMenu Test (middle)")
subMenuBottom := subMenuMiddle.AddSubMenuItemCheckbox("SubMenuBottom - Toggle Panic!", "SubMenu Test (bottom) - Hide/Show Panic!", false)
subMenuBottom2 := subMenuMiddle.AddSubMenuItem("SubMenuBottom - Panic!", "SubMenu Test (bottom)")
mUrl := systray.AddMenuItem("Open UI", "my home")
mQuit := systray.AddMenuItem("退出", "Quit the whole app")
// Sets the icon of a menu item. Only available on Mac.
mQuit.SetIcon(icon.Data)
systray.AddSeparator()
mToggle := systray.AddMenuItem("Toggle", "Toggle the Quit button")
shown := true
toggle := func() {
if shown {
subMenuBottom.Check()
subMenuBottom2.Hide()
mQuitOrig.Hide()
mEnabled.Hide()
shown = false
} else {
subMenuBottom.Uncheck()
subMenuBottom2.Show()
mQuitOrig.Show()
mEnabled.Show()
shown = true
}
}
for {
select {
case <-mChange.ClickedCh:
mChange.SetTitle("I've Changed")
case <-mChecked.ClickedCh:
if mChecked.Checked() {
mChecked.Uncheck()
mChecked.SetTitle("Unchecked")
} else {
mChecked.Check()
mChecked.SetTitle("Checked")
}
case <-mEnabled.ClickedCh:
mEnabled.SetTitle("Disabled")
mEnabled.Disable()
case <-mUrl.ClickedCh:
open.Run("https://www.getlantern.org")
case <-subMenuBottom2.ClickedCh:
panic("panic button pressed")
case <-subMenuBottom.ClickedCh:
toggle()
case <-mToggle.ClickedCh:
toggle()
case <-mQuit.ClickedCh:
systray.Quit()
fmt.Println("Quit2 now...")
return
}
}
}()
}
func onSystrayReady() {
systray.SetTemplateIcon(icon.Data, icon.Data)
systray.SetTooltip("Restictray")
systray.AddMenuItem("... Initializing", "Initializing, please wait.")
addMenuQuit()
cnf, err := restic.ReadConfig()
if err != nil {
handleError(err)
return
}
go func() {
err := wrapper.UpdateLatestSnapshots(cnf)
if err != nil {
handleError(err)
return
}
resetAndBuildMainMenu(cnf)
}()
}
// See https://github.com/fyne-io/systray/tree/master/example for more examples
func resetAndBuildMainMenu(cnf *restic.Config) {
systray.ResetMenu()
addMenuLatestSnapshot()
addMenuNextTime(cnf)
systray.AddSeparator()
mnuBackupNow := systray.AddMenuItem("Backup now", "Backup now")
mnuBrowse := systray.AddMenuItem("Browse backups in Finder...", "Mount and browse backups")
addMenuQuit()
for {
select {
case <-mnuBackupNow.ClickedCh:
onClickedMenuBackupNow(mnuBackupNow, cnf)
case <-mnuBrowse.ClickedCh:
onClickedMenuBrowse(mnuBrowse, cnf)
}
}
}
func onClickedMenuBrowse(browse *systray.MenuItem, cnf *restic.Config) {
err := wrapper.MountBackups(cnf)
if err != nil {
handleError(err)
}
}
func onClickedMenuBackupNow(mnu *systray.MenuItem, cnf *restic.Config) {
mnu.SetTitle("🔄 Backup in progress...")
mnu.Disable()
// TODO after a backup, reinitialize latest snapshot + latest/next menus
// TODO not by calling resetAndBuild again: this is from the for{}?
// TODO how does this interop with a future goroutine that auto-backups?
// TODO need for separate "backupInProgress" bool?
err := wrapper.Backup(cnf)
mnu.SetTitle("Backup now")
mnu.Enable()
if err != nil {
handleError(err)
}
}

64
restic/config.go Normal file
View File

@ -0,0 +1,64 @@
package restic
import (
"encoding/json"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"os"
)
type Config struct {
Repository string `json:"repository"`
Backup string `json:"backup"`
BackupTimeInHours int `json:"backupTimeInHours"`
}
const (
DefaultBackupTimeInHours int = 24
ShortTimeFormat string = "2006-01-02T15:04:05"
)
var home, _ = os.UserHomeDir()
func (cnf *Config) PasswordFile() string {
return home + "/.restic/password.txt"
}
func (cnf *Config) ExcludeFile() string {
return home + "/.restic/excludes.txt"
}
func (cnf *Config) CreateMountDirIfDoesntExist() error {
if _, err := os.Stat(cnf.MountDir()); os.IsNotExist(err) {
return os.Mkdir(cnf.MountDir(), os.ModePerm)
}
return nil
}
func (cnf *Config) MountDir() string {
return home + "/.restic/mnt"
}
func ReadConfig() (*Config, error) {
confData, err := os.ReadFile(home + "/.restic/config.json")
if err != nil {
return nil, fmt.Errorf("No config.json found: %w", err)
}
conf := &Config{}
err = json.Unmarshal(confData, conf)
if err != nil {
return nil, fmt.Errorf("config.json malformed JSON: %w", err)
}
if conf.Repository == "" || conf.Backup == "" {
err := errors.New("config.json is missing required keys 'Backup' or 'Repository'")
return nil, err
}
if conf.BackupTimeInHours == 0 {
log.Warn().Msg("backupTimeInHours missing in config, reverting to 24hrs")
conf.BackupTimeInHours = DefaultBackupTimeInHours
}
return conf, nil
}

118
restic/wrapper.go Normal file
View File

@ -0,0 +1,118 @@
package restic
import (
"encoding/json"
"fmt"
"github.com/rs/zerolog/log"
"os/exec"
"time"
)
type ResticSnapshot struct {
Time time.Time `json:"time"` // format 2023-03-01T16:15:34.111513+01:00
Tree string `json:"tree"`
Paths []string `json:"paths"`
Id string `json:"short_id"`
}
func (rs ResticSnapshot) ShortTime() string {
return rs.Time.Format(ShortTimeFormat)
}
type Wrapper struct {
LatestSnapshots []ResticSnapshot
mountCommand *exec.Cmd
}
func (w *Wrapper) HasSnapshots() bool {
return len(w.LatestSnapshots) > 0
}
func (w *Wrapper) LastSnapshot() ResticSnapshot {
if !w.HasSnapshots() {
return ResticSnapshot{
Id: "(no snapshots yet)",
Time: time.Time{},
}
}
return w.LatestSnapshots[len(w.LatestSnapshots)-1]
}
func resticCmd(args ...string) *exec.Cmd {
cmd := exec.Command("restic", args...)
log.Debug().Msg(cmd.String())
return cmd
}
/*
Expected JSON format:
[
{
"time": "2023-03-01T16:15:34.111513+01:00",
"tree": "d603aa4c6ce2bdef784dbdcd36461970d7f0cc8083d31f46d23cdb9bef172f0a",
"paths": [
"/Users/wgroeneveld"
],
"hostname": "Wouters-M1-Air.local",
"username": "wgroeneveld",
"uid": 501,
"gid": 20,
"id": "31ae2a213c5750c4f86ebe8a8e989a5d4de2963c911e7513f47ca227723a0d95",
"short_id": "31ae2a21"
}
]
*/
func (w *Wrapper) UpdateLatestSnapshots(c *Config) error {
cmd := resticCmd("--json", "--password-file", c.PasswordFile(), "-r", c.Repository, "snapshots")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("restic snapshots cmd: %s: %w", string(out), err)
}
err = json.Unmarshal(out, &w.LatestSnapshots)
if err != nil {
return err
}
log.Debug().Any("snapshots", w.LatestSnapshots).Msg("update")
return nil
}
func (w *Wrapper) MountBackups(c *Config) error {
c.CreateMountDirIfDoesntExist()
if w.mountCommand != nil && w.mountCommand.Process != nil {
// could be killed or terminted due to manual unmount, ignore errors and retry anyway
w.mountCommand.Process.Kill()
}
// Open the folder first: MacFuse could take a second; results in occasional weird errors otherwise.
openFolder(c.MountDir())
w.mountCommand = resticCmd("--password-file", c.PasswordFile(), "-r", c.Repository, "mount", c.MountDir())
err := w.mountCommand.Start() // restic's mount is a blocking call
if err != nil {
return fmt.Errorf("restic mount cmd: %w", err)
}
return nil
}
func openFolder(folder string) error {
cmd := exec.Command("open", folder)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("open mount dir: %s: %w", string(out), err)
}
return nil
}
func (w *Wrapper) Backup(c *Config) error {
cmd := resticCmd("--json", "--password-file", c.PasswordFile(), "-r", c.Repository, "--exclude-file", c.ExcludeFile(), "backup", c.Backup)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("restic backup cmd: %s: %w", string(out), err)
}
log.Debug().Str("out", string(out)).Msg("backup")
return nil
}