From 3a8e84414e5b7bf3c0a5f82075a16339a77c7820 Mon Sep 17 00:00:00 2001 From: wgroeneveld Date: Sat, 4 Mar 2023 20:54:10 +0100 Subject: [PATCH] fyne-io systray attempt; crude first restic wrapper --- .gitignore | 1 + go.mod | 18 ++-- go.sum | 46 ++++------- main.go | 205 ++++++++++++++++++++++++++-------------------- restic/config.go | 64 +++++++++++++++ restic/wrapper.go | 118 ++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 130 deletions(-) create mode 100644 restic/config.go create mode 100644 restic/wrapper.go diff --git a/.gitignore b/.gitignore index cb7d21a..27b93b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.idea/ on_exit_*.txt restictray \ No newline at end of file diff --git a/go.mod b/go.mod index aedeb31..fef26c0 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ce4956d..540a127 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index b0f350d..5fcfe3c 100644 --- a/main.go +++ b/main.go @@ -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) + } +} diff --git a/restic/config.go b/restic/config.go new file mode 100644 index 0000000..6e392c4 --- /dev/null +++ b/restic/config.go @@ -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 +} diff --git a/restic/wrapper.go b/restic/wrapper.go new file mode 100644 index 0000000..80024c8 --- /dev/null +++ b/restic/wrapper.go @@ -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 +}