diff --git a/README.md b/README.md index 938b2ab..2d5154a 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,7 @@ This is new and somewhat work-in-progress/beta software. You might possibly enco - Multiple backup profiles with different schedules and different settings - Optional automatic pruning and checks after backups - Optional automatic retry and removal of stale locks -- Optional restic binary download and updates - - First tries to find restic in user's PATH, but if not found, user can manually click "download"/"update" restic and it'll manage it for them - - This can also be automated daily by enabling "manage_restic" in config +- On Windows it can also download and update it's own self-managed restic if not found in PATH - Open a shell with your profile's environment and restic binary loaded, for restoring or other CLI operations without friction - Mount repository snapshots on macOS/Linux - Requires [FUSE-T](https://www.fuse-t.org) or [macFUSE](https://macfuse.github.io) on macOS. Requires [FUSE](https://github.com/libfuse/libfuse) on Linux @@ -32,15 +30,13 @@ This is new and somewhat work-in-progress/beta software. You might possibly enco ## Installation -Check this repository's [tags](https://tangled.org/devins.page/restray/tags) for build artifacts. I currently build+package for MacOS/Windows and build for Linux. +Grab a build from this repository's [tags](https://tangled.org/devins.page/restray/tags) (MacOS/Windows packaged, Linux as a binary). On MacOS you'll need to [disable Gatekeeper/remove quarantine](https://disable-gatekeeper.github.io) from the `.app`, since I can't pay Apple $100/year to sign it. Sorry. -On MacOS you will need to [disable Gatekeeper/remove quarantine](https://disable-gatekeeper.github.io) from the `.app` as I cannot pay Apple $100 a year to get my app signed. Sorry. +Restray needs [restic](https://restic.net) installed to your PATH on MacOS/Linux (on MacOS, self-managed copy gets killed by code-signing requriment), so install it with your package manager (e.g. `brew install restic` or `apt install restic`). Package managers should also just pull it in as a dependency. On Windows, Restray can download and manage it's own restic binary itself if it isn't already in PATH. -## Compiling +### Building from source -If you're on [Nix(OS)](https://nixos.org)/nix-darwin, just run `nix build`. Or add this repo to your flake and install the package :) - -If you aren't using Nix, install the dependencies from `flake.nix`'s devshell and use `just build` or `just package` to build/package for your current platform. You can also cross-compile to other platforms, for example `just build linux amd64` or `just package windows`. +On [Nix(OS)](https://nixos.org)/nix-darwin, run `nix build` (or add this repo to your flake and simply install the package). Otherwise, install the dependencies from `flake.nix`'s devshell and run `just build` or `just package`. Both default to your current platform. You can also pass a target to cross-compile (e.g. `just build linux amd64`, `just package windows`). ## Configuration @@ -48,7 +44,7 @@ If you aren't using Nix, install the dependencies from `flake.nix`'s devshell an | Key | Type | Default | Description | | ------------------ | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `manage_restic` | bool | `false` | If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub | +| `manage_restic` | bool | `false` | **Windows only.** If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub. | | `notifications` | string | `"none"` | `"all"` (success + errors), `"errors"` (errors only), or `"none"` (silent) | | `schedule_display` | string | `"description"` | `"description"` (human-readable, e.g. "every 6 hours"), `"cron"` (raw expression, e.g. "0 \* \* \* \*"), or `"none"` | | `terminal` | string | `""` | Terminal emulator used in "Shell" and "View Log". If unset, MacOS will use `Terminal.app`. Linux will use default. Not used on Windows. e.g. "Ghostty" | diff --git a/config.go b/config.go index 706fe69..9624dae 100644 --- a/config.go +++ b/config.go @@ -193,6 +193,7 @@ func localResticPath() string { } return filepath.Join(configDir(), "restic") } + func logPath() string { return filepath.Join(configDir(), "restray.log") } func lockPath() string { return filepath.Join(configDir(), "restray.lock") } diff --git a/exec_other.go b/exec_other.go index b24bd49..56b18d7 100644 --- a/exec_other.go +++ b/exec_other.go @@ -3,10 +3,8 @@ package main import ( - "io" "os" "os/exec" - "runtime" "syscall" ) @@ -20,27 +18,3 @@ func interruptProcess(p *os.Process) { func lockFile(f *os.File) error { return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) } - -func clearQuarantine(path string) { - if runtime.GOOS != "darwin" { - return - } - tmp := path + ".tmp" - in, err := os.Open(path) - if err != nil { - return - } - out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY, 0755) - if err != nil { - in.Close() - return - } - _, err = io.Copy(out, in) - in.Close() - out.Close() - if err != nil { - os.Remove(tmp) - return - } - os.Rename(tmp, path) -} diff --git a/exec_windows.go b/exec_windows.go index 76e2aac..ffc8ea5 100644 --- a/exec_windows.go +++ b/exec_windows.go @@ -22,8 +22,6 @@ func interruptProcess(p *os.Process) { p.Kill() } -func clearQuarantine(_ string) {} - func lockFile(f *os.File) error { var ol syscall.Overlapped r, _, err := procLockFileEx.Call( diff --git a/flake.nix b/flake.nix index 0fb3b82..bbbf928 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,8 @@ src = ./.; vendorHash = "sha256-iYM2yvcTsYqn1pyB7jrqroYUfZM4ysWZnXKiLRVqkT0="; + ldflags = ["-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic"]; + env = pkgs.lib.optionalAttrs isDarwin { CGO_CFLAGS = "-mmacosx-version-min=11.0"; CGO_LDFLAGS = "-mmacosx-version-min=11.0"; diff --git a/main.go b/main.go index 64398eb..1c3ea53 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "log" "os" - "path/filepath" "fyne.io/systray" ) @@ -19,12 +18,6 @@ func main() { } defer lock.Close() - if temps, err := filepath.Glob(filepath.Join(configDir(), downloadTmpPattern)); err == nil { - for _, t := range temps { - os.Remove(t) - } - } - f, err := os.OpenFile(logPath(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err == nil { log.SetOutput(f) diff --git a/restic.go b/restic.go index 6d12665..0b51543 100644 --- a/restic.go +++ b/restic.go @@ -1,15 +1,12 @@ package main import ( - "archive/zip" "bufio" - "compress/bzip2" "context" "encoding/json" "fmt" "io" "log" - "net/http" "os" "os/exec" "path/filepath" @@ -22,11 +19,13 @@ import ( "github.com/gen2brain/beeep" ) +// optional restic path baked at build time for Nix and other pkg managers +var resticBuiltinPath string + var ( resticOnce sync.Once resticPath string resticManaged bool - downloading sync.Mutex ) func findRestic() (path string, managed bool) { @@ -39,8 +38,13 @@ func findRestic() (path string, managed bool) { resticPath = p return } - p := localResticPath() - if _, err := os.Stat(p); err == nil { + if resticBuiltinPath != "" { + if _, err := os.Stat(resticBuiltinPath); err == nil { + resticPath = resticBuiltinPath + return + } + } + if p := managedRestic(); p != "" { resticPath = p resticManaged = true } @@ -48,12 +52,6 @@ func findRestic() (path string, managed bool) { return resticPath, resticManaged } -func resetResticPath() { - resticOnce = sync.Once{} - resticPath = "" - resticManaged = false -} - func findResticFromShell() string { if runtime.GOOS == "windows" { return "" @@ -89,20 +87,6 @@ func findResticFromShell() string { return "" } -func resticVersion(path string) string { - cmd := exec.Command(path, "version") - hideWindow(cmd) - out, err := cmd.Output() - if err != nil { - return "" - } - parts := strings.Fields(string(out)) - if len(parts) >= 2 { - return parts[1] - } - return "" -} - func resticEnv(prof Profile) []string { env := os.Environ() vars, err := parseEnvFile(prof.EnvFile) @@ -148,156 +132,6 @@ func repoStatus(prof Profile) repoResult { return repoResult{} } -func latestResticVersion() (string, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Get("https://github.com/restic/restic/releases/latest") - if err != nil { - return "", err - } - resp.Body.Close() - loc := resp.Header.Get("Location") - if loc == "" { - return "", fmt.Errorf("no redirect from github") - } - parts := strings.Split(loc, "/") - v := parts[len(parts)-1] - if len(v) > 0 && v[0] == 'v' { - v = v[1:] - } - return v, nil -} - -func downloadRestic(mStatus prefixedMenuItem) error { - if !downloading.TryLock() { - return nil - } - defer downloading.Unlock() - - mStatus.SetTitle("Fetching latest version...") - version, err := latestResticVersion() - if err != nil { - log.Printf("failed to fetch latest version: %v", err) - return err - } - if version == "" { - log.Print("failed to determine latest version") - return fmt.Errorf("failed to determine latest version") - } - - ext := "bz2" - if runtime.GOOS == "windows" { - ext = "zip" - } - url := fmt.Sprintf("https://github.com/restic/restic/releases/download/v%s/restic_%s_%s_%s.%s", - version, version, runtime.GOOS, runtime.GOARCH, ext) - log.Printf("downloading restic %s", version) - mStatus.SetTitle("Downloading restic " + version + "...") - - resp, err := http.Get(url) - if err != nil { - log.Printf("download failed: %v", err) - return err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - log.Printf("download failed: %s", resp.Status) - return fmt.Errorf("download failed: %s", resp.Status) - } - - if runtime.GOOS == "windows" { - err = extractResticZip(resp.Body) - } else { - err = installBinary(bzip2.NewReader(resp.Body)) - } - if err == nil { - log.Printf("installed restic %s", version) - resetResticPath() - } - return err -} - -const downloadTmpPattern = "restic-download-*" - -func installBinary(r io.Reader) error { - p := localResticPath() - tmp, err := os.CreateTemp(filepath.Dir(p), downloadTmpPattern) - if err != nil { - return err - } - tmpName := tmp.Name() - if _, err := io.Copy(tmp, r); err != nil { - tmp.Close() - os.Remove(tmpName) - return err - } - tmp.Close() - if err := os.Chmod(tmpName, 0755); err != nil { - os.Remove(tmpName) - return err - } - os.Remove(p) - if err := os.Rename(tmpName, p); err != nil { - os.Remove(tmpName) - return err - } - clearQuarantine(p) - return nil -} - -func extractResticZip(r io.Reader) error { - tmp, err := os.CreateTemp("", "restray-download-*.zip") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - defer tmp.Close() - - if _, err := io.Copy(tmp, r); err != nil { - return err - } - - zr, err := zip.OpenReader(tmp.Name()) - if err != nil { - return err - } - defer zr.Close() - - for _, zf := range zr.File { - if strings.HasSuffix(zf.Name, ".exe") { - rc, err := zf.Open() - if err != nil { - return err - } - defer rc.Close() - return installBinary(rc) - } - } - return fmt.Errorf("restic binary not found in zip") -} - -func checkResticUpdate(resticPath string, autoUpdate bool, mUpdate *systray.MenuItem, mStatus prefixedMenuItem, onUpdated func()) { - current := resticVersion(resticPath) - latest, err := latestResticVersion() - if err != nil || current == "" || latest == "" || current == latest { - return - } - log.Printf("restic update available: %s → %s", current, latest) - if autoUpdate { - err = downloadRestic(mStatus) - if err == nil { - onUpdated() - } - return - } - mUpdate.SetTitle("Update Restic (" + current + " → " + latest + ")") - mUpdate.Show() -} - type appState struct { mu sync.Mutex busyProfiles map[int]*exec.Cmd diff --git a/restic_manage_other.go b/restic_manage_other.go new file mode 100644 index 0000000..b3f0f42 --- /dev/null +++ b/restic_manage_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import "fyne.io/systray" + +// macOS/Linux require system restic, the app never downloads or manages one +const selfManagesRestic = false + +func downloadRestic(prefixedMenuItem) error { return nil } +func checkResticUpdate(string, bool, *systray.MenuItem, prefixedMenuItem, func()) {} +func managedRestic() string { return "" } diff --git a/restic_manage_windows.go b/restic_manage_windows.go new file mode 100644 index 0000000..684f493 --- /dev/null +++ b/restic_manage_windows.go @@ -0,0 +1,190 @@ +//go:build windows + +package main + +import ( + "archive/zip" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "fyne.io/systray" +) + +const selfManagesRestic = true + +var downloading sync.Mutex + +func managedRestic() string { + p := localResticPath() + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +func resetResticPath() { + resticOnce = sync.Once{} + resticPath = "" + resticManaged = false +} + +func resticVersion(path string) string { + cmd := exec.Command(path, "version") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return "" + } + parts := strings.Fields(string(out)) + if len(parts) >= 2 { + return parts[1] + } + return "" +} + +func latestResticVersion() (string, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get("https://github.com/restic/restic/releases/latest") + if err != nil { + return "", err + } + resp.Body.Close() + loc := resp.Header.Get("Location") + if loc == "" { + return "", fmt.Errorf("no redirect from github") + } + parts := strings.Split(loc, "/") + v := parts[len(parts)-1] + if len(v) > 0 && v[0] == 'v' { + v = v[1:] + } + return v, nil +} + +func downloadRestic(mStatus prefixedMenuItem) error { + if !downloading.TryLock() { + return nil + } + defer downloading.Unlock() + + mStatus.SetTitle("Fetching latest version...") + version, err := latestResticVersion() + if err != nil { + log.Printf("failed to fetch latest version: %v", err) + return err + } + if version == "" { + log.Print("failed to determine latest version") + return fmt.Errorf("failed to determine latest version") + } + + url := fmt.Sprintf("https://github.com/restic/restic/releases/download/v%s/restic_%s_%s_%s.zip", + version, version, runtime.GOOS, runtime.GOARCH) + log.Printf("downloading restic %s", version) + mStatus.SetTitle("Downloading restic " + version + "...") + + resp, err := http.Get(url) + if err != nil { + log.Printf("download failed: %v", err) + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + log.Printf("download failed: %s", resp.Status) + return fmt.Errorf("download failed: %s", resp.Status) + } + + if err = extractResticZip(resp.Body); err == nil { + log.Printf("installed restic %s", version) + resetResticPath() + } + return err +} + +func installBinary(r io.Reader) error { + p := localResticPath() + tmp, err := os.CreateTemp(filepath.Dir(p), "restic-download-*") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + tmp.Close() + if err := os.Chmod(tmpName, 0755); err != nil { + os.Remove(tmpName) + return err + } + os.Remove(p) + if err := os.Rename(tmpName, p); err != nil { + os.Remove(tmpName) + return err + } + return nil +} + +func extractResticZip(r io.Reader) error { + tmp, err := os.CreateTemp("", "restray-download-*.zip") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + if _, err := io.Copy(tmp, r); err != nil { + return err + } + + zr, err := zip.OpenReader(tmp.Name()) + if err != nil { + return err + } + defer zr.Close() + + for _, zf := range zr.File { + if strings.HasSuffix(zf.Name, ".exe") { + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + return installBinary(rc) + } + } + return fmt.Errorf("restic binary not found in zip") +} + +func checkResticUpdate(resticPath string, autoUpdate bool, mUpdate *systray.MenuItem, mStatus prefixedMenuItem, onUpdated func()) { + current := resticVersion(resticPath) + latest, err := latestResticVersion() + if err != nil || current == "" || latest == "" || current == latest { + return + } + log.Printf("restic update available: %s → %s", current, latest) + if autoUpdate { + err = downloadRestic(mStatus) + if err == nil { + onUpdated() + } + return + } + mUpdate.SetTitle("Update Restic (" + current + " → " + latest + ")") + mUpdate.Show() +} diff --git a/tray.go b/tray.go index af63526..abc0b37 100644 --- a/tray.go +++ b/tray.go @@ -163,7 +163,7 @@ func onReady() { } } - mDownload := systray.AddMenuItem("Download Restic", "Download restic binary") + mDownload := systray.AddMenuItem("Install Restic", "Install restic") mDownload.Hide() mUpdate := systray.AddMenuItem("", "Update restic binary") mUpdate.Hide() @@ -422,7 +422,7 @@ func onReady() { resticPath, managed := findRestic() - if resticPath == "" && cfg.General.ManageRestic { + if resticPath == "" && selfManagesRestic && cfg.General.ManageRestic { mGlobalStatus.SetTitle("Downloading restic...") mGlobalStatus.Show() go doDownload() @@ -629,12 +629,14 @@ func onReady() { cfg := loadConfig() openConsole(selectedProfile(), len(cfg.Profile), cfg.General.Terminal, "") case <-mDownload.ClickedCh: - if !isAnyBusy() { + if !selfManagesRestic { + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") + } else if !isAnyBusy() { hideActions() go doDownload() } case <-mUpdate.ClickedCh: - if !isAnyBusy() { + if selfManagesRestic && !isAnyBusy() { hideActions() go doDownload() }