diff --git a/cmd/restray/backend.go b/cmd/restray/backend.go index 4845a8a..dde36d3 100644 --- a/cmd/restray/backend.go +++ b/cmd/restray/backend.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "os" "os/exec" @@ -114,12 +115,19 @@ func searchBackend(b Backend) (string, bool) { if p := findBackendFromShell(name); p != "" { return p, false } - if p := builtinBackend(b); p != "" { - if _, err := os.Stat(p); err == nil { - return p, false + var builtin string + switch b { + case BackendRestic: + builtin = resticBuiltinPath + case BackendRustic: + builtin = rusticBuiltinPath + } + if builtin != "" { + if _, err := os.Stat(builtin); err == nil { + return builtin, false } } - if p := managedBackend(b); p != "" { + if p := managedBackendPath(b); p != "" { return p, true } if p := bundledBackend(name); p != "" { @@ -128,28 +136,6 @@ func searchBackend(b Backend) (string, bool) { return "", false } -func builtinBackend(b Backend) string { - switch b { - case BackendRestic: - return resticBuiltinPath - case BackendRustic: - return rusticBuiltinPath - default: - return "" - } -} - -func managedBackend(b Backend) string { - switch b { - case BackendRestic: - return managedRestic() - case BackendRustic: - return managedRustic() - default: - return "" - } -} - func bundledBackend(name string) string { if runtime.GOOS != "darwin" { return "" @@ -194,11 +180,11 @@ func findBackendFromShell(name string) string { return "" } -func backendCmd(prof Profile, args ...string) *exec.Cmd { +func backendCmdContext(ctx context.Context, prof Profile, args ...string) *exec.Cmd { path, _ := findBackend(prof) args = backendCommandArgs(prof, args...) log.Printf("[%s] run: %s %s", prof.displayName(), prof.backendName(), strings.Join(args, " ")) - cmd := exec.Command(path, args...) + cmd := exec.CommandContext(ctx, path, args...) cmd.Env = profileEnv(prof) hideWindow(cmd) return cmd diff --git a/cmd/restray/cli.go b/cmd/restray/cli.go index ba6af40..9814d57 100644 --- a/cmd/restray/cli.go +++ b/cmd/restray/cli.go @@ -1,18 +1,15 @@ package main import ( - "bufio" "context" "errors" "fmt" - "io" "log" "os" "os/exec" "os/signal" "runtime" "strings" - "sync" "syscall" "github.com/google/shlex" @@ -20,36 +17,27 @@ import ( "github.com/urfave/cli/v3" ) -const ( - systemConfigDir = "/etc/restray" - systemStateDir = "/var/lib/restray" -) - -func applyRuntimeContext(configDir, stateDir string, setHome bool) { - if configDir != "" { - configDirOverride = configDir - } - if stateDir != "" { - dataDirOverride = stateDir - } - if setHome && dataDirOverride != "" { - os.Setenv("HOME", dataDirOverride) - } -} - func configureRuntime(ctx context.Context, cmd *cli.Command) (context.Context, error) { configDir := cmd.String("config") stateDir := cmd.String("state") system := runtime.GOOS == "linux" && cmd.Bool("system") if system { if configDir == "" { - configDir = systemConfigDir + configDir = "/etc/restray" } if stateDir == "" { - stateDir = systemStateDir + stateDir = "/var/lib/restray" } } - applyRuntimeContext(configDir, stateDir, system) + if configDir != "" { + configDirOverride = configDir + } + if stateDir != "" { + dataDirOverride = stateDir + } + if system && dataDirOverride != "" { + os.Setenv("HOME", dataDirOverride) + } return ctx, nil } @@ -60,14 +48,13 @@ func resolveProfile(cfg Config, name string) (Profile, error) { if name == "" { return cfg.Profiles[0], nil } + var names []string for _, p := range cfg.Profiles { - if p.displayName() == name { + n := p.displayName() + if n == name { return p, nil } - } - var names []string - for _, p := range cfg.Profiles { - names = append(names, p.displayName()) + names = append(names, n) } return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) } @@ -84,19 +71,6 @@ func resolveProfileIndex(cfg Config, key stateKey) (int, Profile, error) { return 0, Profile{}, fmt.Errorf("profile no longer exists") } -func editorCmd() string { - if e := os.Getenv("VISUAL"); e != "" { - return e - } - if e := os.Getenv("EDITOR"); e != "" { - return e - } - if runtime.GOOS == "windows" { - return "notepad" - } - return "vi" -} - func cliWeb(context.Context, *cli.Command) error { if err := ensureConfigFile(); err != nil { return cli.Exit("error: "+err.Error(), 1) @@ -115,7 +89,17 @@ func cliWeb(context.Context, *cli.Command) error { } func cliEdit(path string) error { - editor := editorCmd() + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "vi" + } + } cmd := exec.Command(editor, path) if runtime.GOOS != "windows" || !fileExists(editor) { if parts, err := shlex.Split(editor); err == nil && len(parts) > 0 { @@ -131,16 +115,12 @@ func cliEdit(path string) error { return nil } -func cliRunBackend(prof Profile, args ...string) error { - return cliCommandError(runCLIBackend(prof, args...)) -} - func cliBackendCmd(prof Profile, args ...string) (*exec.Cmd, error) { p, _ := findBackend(prof) if p == "" { return nil, fmt.Errorf("%s not found", prof.backendName()) } - return backendCmd(prof, args...), nil + return backendCmdContext(context.Background(), prof, args...), nil } func runCLIBackend(prof Profile, args ...string) error { @@ -153,8 +133,15 @@ func runCLIBackend(prof Profile, args ...string) error { return cmd.Run() } -func cliRunBackendWithRetry(prof Profile, args []string) error { +func cliRunBackendWithRetry(prof Profile, args []string, logged bool) error { return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error { + if logged { + cmd, err := cliBackendCmd(prof, args...) + if err != nil { + return err + } + return runLoggedCommand(prof, cmd) + } return runCLIBackend(prof, args...) })) } @@ -171,41 +158,15 @@ func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { if err := cmd.Start(); err != nil { return err } - var wg sync.WaitGroup - for _, pipe := range []io.Reader{stdoutPipe, stderrPipe} { - wg.Add(1) - go func(pipe io.Reader) { - defer wg.Done() - scanner := bufio.NewScanner(pipe) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - logPrefixedLine(prof.displayName(), scanner.Text()) - } - if err := scanner.Err(); err != nil { - log.Printf("[%s] reading command output: %v", prof.displayName(), err) - _, _ = io.Copy(io.Discard, pipe) - } - }(pipe) - } + noop := func(string) {} + waitOut := streamStderr(stdoutPipe, prof, noop) + waitErr := streamStderr(stderrPipe, prof, noop) err = cmd.Wait() - wg.Wait() + waitOut() + waitErr() return err } -func runCLIBackendLogged(prof Profile, args ...string) error { - cmd, err := cliBackendCmd(prof, args...) - if err != nil { - return err - } - return runLoggedCommand(prof, cmd) -} - -func cliRunScheduledBackend(prof Profile, args []string) error { - return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error { - return runCLIBackendLogged(prof, args...) - })) -} - func cliCommandError(err error) error { if err == nil { return nil @@ -216,11 +177,18 @@ func cliCommandError(err error) error { return cli.Exit("error: "+err.Error(), 1) } -func cliRunHook(hook string, prof Profile, extraEnv ...string) error { +func requireHook(hook string) error { if hook == "" { return cli.Exit("error: no hook configured", 1) } - cmd := hookCmd(hook, prof, extraEnv...) + return nil +} + +func cliRunHook(hook string, prof Profile, extraEnv ...string) error { + if err := requireHook(hook); err != nil { + return err + } + cmd := hookCmdContext(context.Background(), hook, prof, extraEnv...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -230,29 +198,10 @@ func cliRunHook(hook string, prof Profile, extraEnv ...string) error { } func cliRunHookLogged(hook string, prof Profile, extraEnv ...string) error { - if hook == "" { - return cli.Exit("error: no hook configured", 1) - } - return cliCommandError(runLoggedCommand(prof, hookCmd(hook, prof, extraEnv...))) -} - -func cliBackup(prof Profile, scheduled bool) error { - return cliRunBackendWithRetry(prof, backupArgs(prof, scheduled)) -} - -func cliPrune(prof Profile) error { - return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, forgetArgs(prof))) -} - -func cliCheck(prof Profile) error { - return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, checkArgs(prof))) -} - -func cliUnlock(prof Profile) error { - if !prof.backend().supportsUnlock() { - return cli.Exit("error: "+backendUnavailable(prof, "unlock"), 1) + if err := requireHook(hook); err != nil { + return err } - return cliRunBackend(prof, "unlock") + return cliCommandError(runLoggedCommand(prof, hookCmdContext(context.Background(), hook, prof, extraEnv...))) } func cliShell(prof Profile) error { @@ -288,7 +237,7 @@ func cliMount(prof Profile) error { defer os.Remove(dir) fmt.Fprintf(os.Stderr, "Mounting at %s (press Ctrl+C to unmount)\n", dir) - return cliRunBackend(prof, mountArgs(prof, dir)...) + return cliCommandError(runCLIBackend(prof, mountArgs(prof, dir)...)) } func cliSchedule(prof Profile) error { @@ -315,31 +264,30 @@ func cliSchedule(prof Profile) error { } } - failed, failMsg := runScheduledOperations( - prof, - func() bool { return ctx.Err() != nil }, - func() (bool, string) { - log.Printf("[%s] running backup", prof.displayName()) - if err := cliRunScheduledBackend(prof, backupArgs(prof, true)); err != nil { - return false, "backup failed" - } - return true, "" - }, - func() (bool, string) { - log.Printf("[%s] running prune", prof.displayName()) - if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, forgetArgs(prof))); err != nil { - return false, "prune failed" - } - return true, "" - }, - func() (bool, string) { - log.Printf("[%s] running check", prof.displayName()) - if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, checkArgs(prof))); err != nil { - return false, "check failed" - } - return true, "" - }, - ) + failed := false + failMsg := "" + isInterrupted := func() bool { return ctx.Err() != nil } + if !isInterrupted() && prof.Schedule.BackupEnabled() { + log.Printf("[%s] running backup", prof.displayName()) + if err := cliRunBackendWithRetry(prof, backupArgs(prof, true), true); err != nil { + failed = true + failMsg = "backup failed" + } + } + if !isInterrupted() && !failed && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { + log.Printf("[%s] running prune", prof.displayName()) + if err := cliRunBackendWithRetry(prof, addRetryLockArgs(prof, forgetArgs(prof)), true); err != nil { + failed = true + failMsg = "prune failed" + } + } + if !isInterrupted() && !failed && prof.Schedule.Check { + log.Printf("[%s] running check", prof.displayName()) + if err := cliRunBackendWithRetry(prof, addRetryLockArgs(prof, checkArgs(prof)), true); err != nil { + failed = true + failMsg = "check failed" + } + } interrupted := ctx.Err() != nil if prof.PostHook != "" { @@ -384,7 +332,7 @@ func buildDaemonSchedule() (*cron.Cron, error) { continue } _, err := sched.AddFunc(prof.Schedule.Cron, func() { - runScheduleCallback(key, nil, func(_ Config, _ int, prof Profile) { + runScheduleCallback(key, func(prof Profile) { log.Printf("[%s] starting scheduled run", prof.displayName()) if err := cliSchedule(prof); err != nil { log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err) @@ -432,14 +380,6 @@ func cliDaemon(context.Context, *cli.Command) error { return nil } -func profileFlag() *cli.StringFlag { - return &cli.StringFlag{ - Name: "profile", - Aliases: []string{"p"}, - Usage: "profile name (default: first profile)", - } -} - func profileAction(fn func(Profile) error) cli.ActionFunc { return func(_ context.Context, cmd *cli.Command) error { prof, err := resolveProfile(loadConfig(), cmd.String("profile")) @@ -463,7 +403,7 @@ func buildCLI() *cli.Command { } flags = append(flags, &cli.BoolFlag{Name: "verbose", Usage: "also write tray logs to stderr"}, - profileFlag(), + &cli.StringFlag{Name: "profile", Aliases: []string{"p"}, Usage: "profile name (default: first profile)"}, ) return &cli.Command{ Name: "restray", @@ -492,10 +432,15 @@ func buildCLI() *cli.Command { Aliases: []string{"op"}, Usage: "Run a restray operation", Commands: []*cli.Command{ - {Name: "backup", Usage: "Run backup", Action: profileAction(func(p Profile) error { return cliBackup(p, false) })}, - {Name: "prune", Usage: "Prune old snapshots", Action: profileAction(cliPrune)}, - {Name: "check", Usage: "Verify repository integrity", Action: profileAction(cliCheck)}, - {Name: "unlock", Usage: "Remove stale locks", Action: profileAction(cliUnlock)}, + {Name: "backup", Usage: "Run backup", Action: profileAction(func(p Profile) error { return cliRunBackendWithRetry(p, backupArgs(p, false), false) })}, + {Name: "prune", Usage: "Prune old snapshots", Action: profileAction(func(p Profile) error { return cliRunBackendWithRetry(p, addRetryLockArgs(p, forgetArgs(p)), false) })}, + {Name: "check", Usage: "Verify repository integrity", Action: profileAction(func(p Profile) error { return cliRunBackendWithRetry(p, addRetryLockArgs(p, checkArgs(p)), false) })}, + {Name: "unlock", Usage: "Remove stale locks", Action: profileAction(func(p Profile) error { + if !p.backend().supportsUnlock() { + return cli.Exit("error: "+backendUnavailable(p, "unlock"), 1) + } + return cliCommandError(runCLIBackend(p, "unlock")) + })}, {Name: "shell", Usage: "Open shell with selected backend environment", Action: profileAction(cliShell)}, {Name: "mount", Usage: "Mount repository (Ctrl+C to unmount)", Action: profileAction(cliMount)}, {Name: "pre-hook", Usage: "Run pre-hook command", Action: profileAction(func(p Profile) error { return cliRunHook(p.PreHook, p) })}, @@ -542,8 +487,7 @@ func runCLI(args []string) int { exitCodeResult := 0 cli.OsExiter = func(code int) { exitCodeResult = code } - fullArgs := append([]string{"restray"}, args...) - if err := buildCLI().Run(context.Background(), fullArgs); err != nil && exitCodeResult == 0 { + if err := buildCLI().Run(context.Background(), append([]string{"restray"}, args...)); err != nil && exitCodeResult == 0 { exitCodeResult = 1 } return exitCodeResult diff --git a/cmd/restray/config.go b/cmd/restray/config.go index 8e1e1fc..ce8cd20 100644 --- a/cmd/restray/config.go +++ b/cmd/restray/config.go @@ -245,8 +245,7 @@ func dataDir() string { return configDir() } -func logPath() string { return filepath.Join(dataDir(), "restray.log") } -func lockPath() string { return filepath.Join(dataDir(), "restray.lock") } +func logPath() string { return filepath.Join(dataDir(), "restray.log") } func loadConfig() Config { cfg := Config{} @@ -320,21 +319,20 @@ func loadConfig() Config { return cfg } -func defaultConfigContents() string { - return strings.ReplaceAll(defaultConfigTemplate, "{{ENV_FILE}}", filepath.ToSlash(defaultEnvPath("default"))) -} - -func ensureConfigFile() error { - p := configPath() - if _, err := os.Stat(p); err == nil { +func ensureFile(path, data string) error { + if _, err := os.Stat(path); err == nil { return nil } else if !os.IsNotExist(err) { return err } - if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } - return os.WriteFile(p, []byte(defaultConfigContents()), 0600) + return os.WriteFile(path, []byte(data), 0600) +} + +func ensureConfigFile() error { + return ensureFile(configPath(), strings.ReplaceAll(defaultConfigTemplate, "{{ENV_FILE}}", filepath.ToSlash(defaultEnvPath("default")))) } func configWritable() bool { @@ -358,15 +356,7 @@ func saveConfig(cfg Config) error { } func ensureEnvFile(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - return os.WriteFile(path, []byte(defaultEnvTemplate), 0600) + return ensureFile(path, defaultEnvTemplate) } func fileExists(path string) bool { @@ -386,25 +376,30 @@ func watchConfig(onChange func()) { return } - go func() { - for err := range watcher.Errors { - log.Printf("config watch: %v", err) - } - }() go func() { var debounce *time.Timer - for event := range watcher.Events { - ext := filepath.Ext(event.Name) - if ext != ".toml" && ext != ".env" { - continue - } - action := event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || - event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) - if action { - if debounce != nil { - debounce.Stop() + for { + select { + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("config watch: %v", err) + case event, ok := <-watcher.Events: + if !ok { + return + } + ext := filepath.Ext(event.Name) + if ext != ".toml" && ext != ".env" { + continue + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || + event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(200*time.Millisecond, onChange) } - debounce = time.AfterFunc(200*time.Millisecond, onChange) } } }() diff --git a/cmd/restray/config_test.go b/cmd/restray/config_test.go index c8baf5d..370c513 100644 --- a/cmd/restray/config_test.go +++ b/cmd/restray/config_test.go @@ -56,41 +56,6 @@ func TestProfileRepositoryError(t *testing.T) { } } -func TestProfileScheduleError(t *testing.T) { - enabled := true - disabled := false - tests := []struct { - name string - backup *bool - paths []string - prune bool - want string - }{ - {name: "backup enabled by default without paths", want: "No paths configured"}, - {name: "backup explicitly enabled without paths", backup: &enabled, want: "No paths configured"}, - {name: "backup enabled with paths", paths: []string{"/data"}}, - {name: "prune only without paths", backup: &disabled, prune: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - envFile := filepath.Join(t.TempDir(), "profile.env") - if err := os.WriteFile(envFile, []byte("RESTIC_REPOSITORY=/repo\nRESTIC_PASSWORD=secret\n"), 0600); err != nil { - t.Fatal(err) - } - prof := Profile{ - Backend: "restic", - EnvFile: envFile, - Schedule: Schedule{Backup: tt.backup, Prune: tt.prune}, - Backup: Backup{Paths: tt.paths}, - } - if got := prof.scheduleError(); got != tt.want { - t.Fatalf("scheduleError() = %q, want %q", got, tt.want) - } - }) - } -} - func TestLoadConfig(t *testing.T) { dir := t.TempDir() old := configDirOverride diff --git a/cmd/restray/icons.go b/cmd/restray/icons.go index ba46b64..3587f1d 100644 --- a/cmd/restray/icons.go +++ b/cmd/restray/icons.go @@ -6,6 +6,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "fyne.io/systray" @@ -20,8 +21,9 @@ var ( animMu sync.Mutex animStop chan struct{} - iconMu sync.RWMutex - iconMode = defaultIconMode() + iconMu sync.RWMutex + iconMode = defaultIconMode() + iconDirty atomic.Bool alertMu sync.RWMutex currentIcon = "idle" @@ -38,11 +40,11 @@ func normalizeIconMode(mode string) string { } } -func applyIconMode(mode string) bool { +func applyIconMode(mode string) { mode = normalizeIconMode(mode) iconMu.Lock() - changed := iconMode != mode + prev := iconMode iconMode = mode iconMu.Unlock() @@ -51,10 +53,9 @@ func applyIconMode(mode string) bool { } else { stopIconThemeWatcher() } - if changed || len(currentAlertIcon()) == 0 { + if prev != mode || len(currentAlertIcon()) == 0 { initAlertIcon() } - return changed } func currentIconMode() string { @@ -63,22 +64,22 @@ func currentIconMode() string { return iconMode } -func cancelAnimation() { +func playAnimation(name string) { animMu.Lock() - defer animMu.Unlock() if animStop != nil { close(animStop) animStop = nil } -} - -func playAnimation(name string) { - cancelAnimation() - animMu.Lock() stop := make(chan struct{}) animStop = stop animMu.Unlock() - defer clearAnimation(stop) + defer func() { + animMu.Lock() + defer animMu.Unlock() + if animStop == stop { + animStop = nil + } + }() frames, template := loadIconFrames(name) for _, frame := range frames { @@ -86,20 +87,16 @@ func playAnimation(name string) { case <-stop: return default: - setIcon(frame, template) + if template { + systray.SetTemplateIcon(frame, frame) + } else { + systray.SetIcon(frame) + } time.Sleep(50 * time.Millisecond) } } } -func clearAnimation(stop chan struct{}) { - animMu.Lock() - defer animMu.Unlock() - if animStop == stop { - animStop = nil - } -} - func loadIcons(prefix string) [][]byte { entries, err := iconFS.ReadDir("icons/generated") if err != nil { @@ -137,14 +134,6 @@ func loadIconFrames(name string) ([][]byte, bool) { return frames, variant.template } -func setIcon(icon []byte, template bool) { - if template { - systray.SetTemplateIcon(icon, icon) - return - } - systray.SetIcon(icon) -} - func setIconAnimated(name string) { alertMu.Lock() currentIcon = name @@ -152,13 +141,6 @@ func setIconAnimated(name string) { go playAnimation(name) } -func refreshIcon() { - alertMu.RLock() - name := currentIcon - alertMu.RUnlock() - setIconAnimated(name) -} - func initAlertIcon() { frames, _ := loadIconFrames("fail") if len(frames) == 0 { diff --git a/cmd/restray/icons_theme_other.go b/cmd/restray/icons_theme_other.go index eb109ae..c21627a 100644 --- a/cmd/restray/icons_theme_other.go +++ b/cmd/restray/icons_theme_other.go @@ -10,8 +10,20 @@ import ( dark "github.com/thiagokokada/dark-mode-go" ) -type iconThemeWatcher struct { - cancel context.CancelFunc +var ( + systemDark bool + themeCancel context.CancelFunc + themeGen uint64 +) + +func setSystemDarkMode(dark bool) bool { + iconMu.Lock() + defer iconMu.Unlock() + if systemDark == dark { + return false + } + systemDark = dark + return true } func defaultIconMode() string { @@ -21,28 +33,16 @@ func defaultIconMode() string { return "color" } -var ( - systemDark bool - themeWatcher *iconThemeWatcher -) - -func setSystemDarkMode(dark bool) bool { - iconMu.Lock() - changed := systemDark != dark - systemDark = dark - iconMu.Unlock() - return changed -} - func startIconThemeWatcher() { iconMu.Lock() - if themeWatcher != nil { + if themeCancel != nil { iconMu.Unlock() return } ctx, cancel := context.WithCancel(context.Background()) - watcher := &iconThemeWatcher{cancel: cancel} - themeWatcher = watcher + themeGen++ + gen := themeGen + themeCancel = cancel iconMu.Unlock() if isDark, err := dark.IsDarkMode(); err == nil { @@ -55,12 +55,12 @@ func startIconThemeWatcher() { if err != nil { log.Printf("icon theme watcher failed: %v", err) cancel() - clearIconThemeWatcher(watcher) + clearIconThemeWatcher(gen) return } - go func() { - defer clearIconThemeWatcher(watcher) + go func(gen uint64) { + defer clearIconThemeWatcher(gen) for events != nil || errs != nil { select { case isDark, ok := <-events: @@ -70,7 +70,8 @@ func startIconThemeWatcher() { } if setSystemDarkMode(isDark) && currentIconMode() == "mono" { initAlertIcon() - refreshIcon() + iconDirty.Store(true) + state.signal() } case err, ok := <-errs: if !ok { @@ -82,27 +83,25 @@ func startIconThemeWatcher() { } } } - }() + }(gen) } func stopIconThemeWatcher() { iconMu.Lock() - watcher := themeWatcher - if watcher == nil { - iconMu.Unlock() - return - } - themeWatcher = nil + cancel := themeCancel + themeCancel = nil iconMu.Unlock() - watcher.cancel() + if cancel != nil { + cancel() + } } -func clearIconThemeWatcher(watcher *iconThemeWatcher) { +func clearIconThemeWatcher(gen uint64) { iconMu.Lock() - if themeWatcher == watcher { - themeWatcher = nil + defer iconMu.Unlock() + if themeGen == gen { + themeCancel = nil } - iconMu.Unlock() } func platformIconVariant(mode string) iconVariant { diff --git a/cmd/restray/main.go b/cmd/restray/main.go index 00854eb..98f43cf 100644 --- a/cmd/restray/main.go +++ b/cmd/restray/main.go @@ -4,6 +4,7 @@ import ( "io" "log" "os" + "path/filepath" "fyne.io/systray" "github.com/gen2brain/beeep" @@ -25,8 +26,12 @@ func runTray(verbose bool) { return } - lock, err := os.OpenFile(lockPath(), os.O_CREATE|os.O_RDWR, 0600) - if err != nil || lockFile(lock) != nil { + lock, err := os.OpenFile(filepath.Join(dataDir(), "restray.lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return + } + if lockFile(lock) != nil { + lock.Close() return } defer lock.Close() diff --git a/cmd/restray/manage.go b/cmd/restray/manage.go index ef21904..3411bd2 100644 --- a/cmd/restray/manage.go +++ b/cmd/restray/manage.go @@ -16,69 +16,26 @@ import ( "path/filepath" "runtime" "strings" - "sync" "time" - - "fyne.io/systray" ) const selfManagesBackend = true -var downloading sync.Mutex - -func resticBinaryName() string { - if runtime.GOOS == "windows" { - return "restic.exe" - } - return "restic" -} - -func managedRestic() string { - p := filepath.Join(dataDir(), resticBinaryName()) - if _, err := os.Stat(p); err == nil { - return p - } - return "" -} - -func rusticBinaryName() string { +func exeName(base string) string { if runtime.GOOS == "windows" { - return "rustic.exe" + return base + ".exe" } - return "rustic" + return base } -func managedRustic() string { - p := filepath.Join(dataDir(), rusticBinaryName()) +func managedBackendPath(b Backend) string { + p := filepath.Join(dataDir(), exeName(string(b))) if _, err := os.Stat(p); err == nil { return p } return "" } -func resetBackendPath(b Backend) { - if state := backendPaths[b]; state != nil { - state.Lock() - defer state.Unlock() - state.path = "" - state.managed = false - } -} - -func binaryVersion(path string, args ...string) string { - cmd := exec.Command(path, args...) - hideWindow(cmd) - out, err := cmd.Output() - if err != nil { - return "" - } - parts := strings.Fields(string(out)) - if len(parts) >= 2 { - return parts[1] - } - return "" -} - func latestGitHubRelease(repo string) (string, error) { client := &http.Client{ Timeout: 10 * time.Second, @@ -90,65 +47,91 @@ func latestGitHubRelease(repo string) (string, error) { if err != nil { return "", err } - resp.Body.Close() + defer resp.Body.Close() loc := resp.Header.Get("Location") if loc == "" { return "", fmt.Errorf("no redirect from github") } v := strings.TrimPrefix(filepath.Base(loc), "v") - if v == "" || v == "." { + if v == "" { return "", fmt.Errorf("invalid release redirect from github") } return v, nil } -func downloadRestic(mStatus prefixedMenuItem) error { - if !downloading.TryLock() { - return nil +func reportStatus(status func(string), msg string) { + if status != nil { + status(msg) } - defer downloading.Unlock() +} - mStatus.SetTitle("Fetching latest version...") - version, err := latestGitHubRelease("restic/restic") +func fetchOK(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("download failed: %s", resp.Status) + } + return resp.Body, nil +} + +func fetchAndInstall(status func(string), repo string, b Backend, buildURL func(version string) (string, error), extract func(io.Reader) error) error { + if b == BackendRustic { + reportStatus(status, "Fetching latest Rustic version...") + } else { + reportStatus(status, "Fetching latest version...") + } + version, err := latestGitHubRelease(repo) 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, err := buildURL(version) + if err != nil { + return err } - - ext := "bz2" - if runtime.GOOS == "windows" { - ext = "zip" + display := string(b) + if b == BackendRustic { + display = "Rustic" } - 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 + "...") + log.Printf("downloading %s %s", string(b), version) + reportStatus(status, "Downloading "+display+" "+version+"...") - resp, err := http.Get(url) + body, err := fetchOK(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) + defer body.Close() + + if err := extract(body); err != nil { + return err } + log.Printf("installed %s %s", string(b), version) + state := backendPaths[b] + state.Lock() + state.path = "" + state.managed = false + state.Unlock() + return nil +} - if err = extractRestic(resp.Body); err == nil { - log.Printf("installed restic %s", version) - resetBackendPath(BackendRestic) +func downloadRestic(status func(string)) error { + ext := "bz2" + if runtime.GOOS == "windows" { + ext = "zip" } - return err + return fetchAndInstall(status, "restic/restic", BackendRestic, func(version string) (string, error) { + return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%s/restic_%s_%s_%s.%s", + version, version, runtime.GOOS, runtime.GOARCH, ext), nil + }, extractRestic) } func extractRestic(r io.Reader) error { if runtime.GOOS != "windows" { - return installNamedBinary(bzip2.NewReader(r), resticBinaryName()) + return installNamedBinary(bzip2.NewReader(r), exeName("restic")) } tmp, err := os.CreateTemp("", "restray-download-*.zip") @@ -174,57 +157,29 @@ func extractRestic(r io.Reader) error { if err != nil { return err } - defer rc.Close() - return installNamedBinary(rc, resticBinaryName()) + err = installNamedBinary(rc, exeName("restic")) + rc.Close() + return err } } return fmt.Errorf("restic binary not found in zip") } -func rusticTarget() (string, error) { - if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" { - return "x86_64-pc-windows-msvc", nil - } - if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { - return "x86_64-unknown-linux-musl", nil - } - if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { - return "aarch64-unknown-linux-musl", nil - } - return "", fmt.Errorf("rustic managed downloads are unsupported on %s/%s", runtime.GOOS, runtime.GOARCH) -} - -func downloadRustic(mStatus prefixedMenuItem) error { - if !downloading.TryLock() { - return nil - } - defer downloading.Unlock() - mStatus.SetTitle("Fetching latest Rustic version...") - version, err := latestGitHubRelease("rustic-rs/rustic") - if err != nil { - return err - } - target, err := rusticTarget() - if err != nil { - return err - } - ext := "tar.gz" - url := fmt.Sprintf("https://github.com/rustic-rs/rustic/releases/download/v%s/rustic-v%s-%s.%s", version, version, target, ext) - mStatus.SetTitle("Downloading Rustic " + version + "...") - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed: %s", resp.Status) - } - err = extractRusticTar(resp.Body) - if err == nil { - resetBackendPath(BackendRustic) - log.Printf("installed rustic %s", version) - } - return err +func downloadRustic(status func(string)) error { + return fetchAndInstall(status, "rustic-rs/rustic", BackendRustic, func(version string) (string, error) { + var target string + switch runtime.GOOS + "/" + runtime.GOARCH { + case "windows/amd64": + target = "x86_64-pc-windows-msvc" + case "linux/amd64": + target = "x86_64-unknown-linux-musl" + case "linux/arm64": + target = "aarch64-unknown-linux-musl" + default: + return "", fmt.Errorf("rustic managed downloads are unsupported on %s/%s", runtime.GOOS, runtime.GOARCH) + } + return fmt.Sprintf("https://github.com/rustic-rs/rustic/releases/download/v%s/rustic-v%s-%s.tar.gz", version, version, target), nil + }, extractRusticTar) } func extractRusticTar(r io.Reader) error { @@ -233,6 +188,7 @@ func extractRusticTar(r io.Reader) error { return err } defer gz.Close() + want := exeName("rustic") tr := tar.NewReader(gz) for { h, err := tr.Next() @@ -242,8 +198,8 @@ func extractRusticTar(r io.Reader) error { if err != nil { return err } - if filepath.Base(h.Name) == rusticBinaryName() { - return installNamedBinary(tr, rusticBinaryName()) + if filepath.Base(h.Name) == want { + return installNamedBinary(tr, want) } } return fmt.Errorf("rustic binary not found in archive") @@ -259,63 +215,52 @@ func installNamedBinary(r io.Reader, name string) error { return err } tmpName := tmp.Name() + defer os.Remove(tmpName) if _, err := io.Copy(tmp, r); err != nil { tmp.Close() - os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { - os.Remove(tmpName) return err } if err := os.Chmod(tmpName, 0755); err != nil { - os.Remove(tmpName) return err } if runtime.GOOS == "windows" { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - os.Remove(tmpName) return err } } - if err := os.Rename(tmpName, p); err != nil { - os.Remove(tmpName) - return err - } - return nil + return os.Rename(tmpName, p) } -func downloadBackend(prof Profile, status prefixedMenuItem) error { +func downloadBackend(prof Profile, status func(string)) error { if prof.backend() == BackendRustic { return downloadRustic(status) } return downloadRestic(status) } -func checkBackendUpdate(prof Profile, path string, auto bool, item *systray.MenuItem, status prefixedMenuItem, done func()) { - backend := prof.backend() +func checkBackendUpdate(prof Profile, path string) string { versionArgs := []string{"version"} repo := "restic/restic" label := "Restic" - download := downloadRestic - if backend == BackendRustic { + if prof.backend() == BackendRustic { versionArgs = []string{"--version"} repo = "rustic-rs/rustic" label = "Rustic" - download = downloadRustic } - current := strings.TrimPrefix(binaryVersion(path, versionArgs...), "v") + current := "" + verCmd := exec.Command(path, versionArgs...) + hideWindow(verCmd) + if out, err := verCmd.Output(); err == nil { + if parts := strings.Fields(string(out)); len(parts) >= 2 { + current = strings.TrimPrefix(parts[1], "v") + } + } latest, err := latestGitHubRelease(repo) - latest = strings.TrimPrefix(latest, "v") if err != nil || current == "" || latest == current { - return + return "" } log.Printf("%s update available: %s → %s", strings.ToLower(label), current, latest) - if auto { - if download(status) == nil { - done() - } - return - } - item.SetTitle("Update " + label + " (" + current + " → " + latest + ")") - item.Show() + return "Update " + label + " (" + current + " → " + latest + ")" } diff --git a/cmd/restray/manage_other.go b/cmd/restray/manage_other.go index b1a6734..fc567ed 100644 --- a/cmd/restray/manage_other.go +++ b/cmd/restray/manage_other.go @@ -2,11 +2,8 @@ package main -import "fyne.io/systray" - const selfManagesBackend = false -func managedRestic() string { return "" } -func managedRustic() string { return "" } -func downloadBackend(Profile, prefixedMenuItem) error { return nil } -func checkBackendUpdate(Profile, string, bool, *systray.MenuItem, prefixedMenuItem, func()) {} +func managedBackendPath(Backend) string { return "" } +func downloadBackend(Profile, func(string)) error { return nil } +func checkBackendUpdate(Profile, string) string { return "" } diff --git a/cmd/restray/operations.go b/cmd/restray/operations.go index 3284b5a..f823a7d 100644 --- a/cmd/restray/operations.go +++ b/cmd/restray/operations.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "io" @@ -13,7 +14,6 @@ import ( "strings" "time" - "fyne.io/systray" "github.com/dustin/go-humanize" "github.com/gen2brain/beeep" ) @@ -35,14 +35,30 @@ func parseBackupStatus(line string) (backupStatus, bool) { return status, true } -type lastLineLogger struct { - last string - done chan struct{} -} - -func (s *lastLineLogger) wait() string { - <-s.done - return s.last +func streamStderr(pipe io.Reader, prof Profile, status func(string)) func() string { + done := make(chan struct{}) + var last string + go func() { + defer close(done) + scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + logPrefixedLine(prof.displayName(), line) + last = line + if trimmed := strings.TrimSpace(line); trimmed != "" { + status(trimmed) + } + } + if err := scanner.Err(); err != nil { + log.Printf("[%s] reading command output: %v", prof.displayName(), err) + _, _ = io.Copy(io.Discard, pipe) + } + }() + return func() string { + <-done + return last + } } func logPrefixedLine(name, line string) { @@ -57,66 +73,102 @@ func logPrefixedOutput(name, out string) { } } -func streamLogLines(pipe io.Reader, name string, onLine func(string)) *lastLineLogger { - sl := &lastLineLogger{done: make(chan struct{})} - go func() { - defer close(sl.done) - scanner := bufio.NewScanner(pipe) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := scanner.Text() - logPrefixedLine(name, line) - sl.last = line - if onLine != nil { - onLine(line) - } - } - if err := scanner.Err(); err != nil { - log.Printf("[%s] reading command output: %v", name, err) - _, _ = io.Copy(io.Discard, pipe) - } - }() - return sl +func interruptedOr(handle backendStarter, msg string) string { + if handle.context().Err() != nil { + return "Interrupted by user" + } + return msg } -func streamStderr(pipe io.Reader, prof Profile, mStatus prefixedMenuItem) *lastLineLogger { - return streamLogLines(pipe, prof.displayName(), func(line string) { - if trimmed := strings.TrimSpace(line); trimmed != "" { - mStatus.SetTitle(trimmed) - } - }) +func (h operationHandle) failureOr(def string) string { + if msg := h.failure(); msg != "" { + return msg + } + return def } -func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { - cmd := backendCmd(prof, args...) - var stdout strings.Builder - cmd.Stdout = &stdout - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return err.Error(), err +func notifyMode() string { return strings.ToLower(state.snapshot().cfg.GUI.Notifications) } + +func reportUnlockOr(handle operationHandle, args []string, msg string) bool { + if len(args) == 1 && args[0] == "unlock" { + handle.status("Unlocking repository...") + return true + } + if msg != "" { + handle.status(msg) } - setProfileBusyCmd(key, cmd) + return false +} +func formatBackupStatus(status backupStatus) string { + switch { + case status.Total > 0: + return fmt.Sprintf("%d%% completed, %s of %s", + int(status.Percent*100), humanize.Bytes(status.Bytes), humanize.Bytes(status.Total)) + case status.TotalFiles > 0: + return fmt.Sprintf("Scanning: %d / %d files", + status.FilesDone, status.TotalFiles) + default: + return fmt.Sprintf("Scanning: %d files", status.FilesDone) + } +} + +type backendStarter interface { + context() context.Context + started(*os.Process) +} + +func startPipedBackend(handle backendStarter, prof Profile, cmd *exec.Cmd) (io.ReadCloser, error) { + pipe, err := cmd.StderrPipe() + if err != nil { + return nil, err + } if err := cmd.Start(); err != nil { log.Printf("[%s] start failed: %v", prof.displayName(), err) - return err.Error(), err + return nil, err } + handle.started(cmd.Process) + return pipe, nil +} - sl := streamStderr(stderrPipe, prof, mStatus) - err = cmd.Wait() - lastStderr := sl.wait() - if out := stdout.String(); out != "" { - logPrefixedOutput(prof.displayName(), out) - } +func waitBackend(handle operationHandle, prof Profile, cmd *exec.Cmd, stderrPipe io.Reader) (string, int, error) { + last := streamStderr(stderrPipe, prof, handle.status) + err := cmd.Wait() + lastLine := last() if err == nil { - log.Printf("[%s] done (%s): %s", prof.displayName(), prof.backendName(), args[0]) - return "", nil + log.Printf("[%s] done", prof.displayName()) + return "", 0, nil } - msg, code := classifyBackendError(prof, err, lastStderr) + msg, code := classifyBackendError(prof, err, lastLine) + msg = interruptedOr(handle, msg) if msg != "" { - mStatus.SetTitle(msg) + handle.status(msg) } log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) + return msg, code, err +} + +func runWithUnlockRetry(handle operationHandle, prof Profile, args []string, statusMsg string, once func([]string) error) error { + return runBackendWithRetry(prof, args, func(retryArgs ...string) error { + if !reportUnlockOr(handle, retryArgs, statusMsg) && statusMsg != "" { + handle.fail("") + } + return once(retryArgs) + }) +} + +func runBackendOnce(handle operationHandle, prof Profile, args ...string) (string, error) { + cmd := backendCmdContext(handle.context(), prof, args...) + var stdout strings.Builder + cmd.Stdout = &stdout + stderrPipe, err := startPipedBackend(handle, prof, cmd) + if err != nil { + return interruptedOr(handle, err.Error()), err + } + msg, _, err := waitBackend(handle, prof, cmd, stderrPipe) + if out := stdout.String(); out != "" { + logPrefixedOutput(prof.displayName(), out) + } return msg, err } @@ -136,88 +188,67 @@ func runBackendWithRetry(prof Profile, args []string, run func(...string) error) return run(args...) } -func runBackend(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { +func runBackend(handle operationHandle, prof Profile, args ...string) (string, error) { args = addRetryLockArgs(prof, args) var msg string - err := runBackendWithRetry(prof, args, func(args ...string) error { - if len(args) == 1 && args[0] == "unlock" { - mStatus.SetTitle("Unlocking repository...") - } + err := runWithUnlockRetry(handle, prof, args, "", func(a []string) error { var err error - msg, err = runBackendOnce(key, prof, mStatus, args...) + msg, err = runBackendOnce(handle, prof, a...) return err }) return msg, err } -func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int, error) { - cmd := backendCmd(prof, args...) +func doBackupOnce(handle operationHandle, prof Profile, args []string) (bool, int, error) { + cmd := backendCmdContext(handle.context(), prof, args...) if prof.backend() == BackendRestic { cmd.Env = setEnv(cmd.Env, "RESTIC_PROGRESS_FPS", "4") } stdoutPipe, err := cmd.StdoutPipe() if err != nil { - setProfileFailed(key, err.Error()) + handle.fail(err.Error()) return false, -1, err } - stderrPipe, err := cmd.StderrPipe() + stderrPipe, err := startPipedBackend(handle, prof, cmd) if err != nil { - setProfileFailed(key, err.Error()) - return false, -1, err - } - setProfileBusyCmd(key, cmd) - - if err := cmd.Start(); err != nil { - log.Printf("[%s] start failed: %v", prof.displayName(), err) - setProfileFailed(key, err.Error()) + msg := interruptedOr(handle, err.Error()) + handle.fail(msg) return false, -1, err } - sl := streamStderr(stderrPipe, prof, mStatus) - - scanner := bufio.NewScanner(stdoutPipe) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := scanner.Text() - - status, ok := parseBackupStatus(line) - if !ok { - logPrefixedLine(prof.displayName(), line) - continue + stdoutDone := make(chan struct{}) + go func() { + defer close(stdoutDone) + scanner := bufio.NewScanner(stdoutPipe) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if status, ok := parseBackupStatus(line); ok { + handle.status(formatBackupStatus(status)) + } else { + logPrefixedLine(prof.displayName(), line) + } } - switch { - case status.Total > 0: - mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s", - int(status.Percent*100), humanize.Bytes(status.Bytes), humanize.Bytes(status.Total))) - case status.TotalFiles > 0: - mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files", - status.FilesDone, status.TotalFiles)) - default: - mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", status.FilesDone)) + if err := scanner.Err(); err != nil { + log.Printf("[%s] reading backup output: %v", prof.displayName(), err) + _, _ = io.Copy(io.Discard, stdoutPipe) } - } - if err := scanner.Err(); err != nil { - log.Printf("[%s] reading backup output: %v", prof.displayName(), err) - _, _ = io.Copy(io.Discard, stdoutPipe) - } + }() - err = cmd.Wait() - lastStderr := sl.wait() + msg, code, err := waitBackend(handle, prof, cmd, stderrPipe) + <-stdoutDone if err != nil { - msg, code := classifyBackendError(prof, err, lastStderr) - log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) if prof.backend() == BackendRestic && code == 3 { log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) return true, 3, nil } - setProfileFailed(key, msg) + handle.fail(msg) return false, code, err } - log.Printf("[%s] done: backup", prof.displayName()) return true, 0, nil } -func doBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { +func doBackup(handle operationHandle, prof Profile, scheduled bool) (bool, int) { jsonFlag := "--json" if prof.backend() == BackendRustic { jsonFlag = "--json-progress" @@ -226,39 +257,27 @@ func doBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, scheduled bo var ok bool var code int - err := runBackendWithRetry(prof, args, func(args ...string) error { - if len(args) == 1 && args[0] == "unlock" { - mStatus.SetTitle("Unlocking repository...") - } else { - mStatus.SetTitle("Backing up...") - setProfileFailed(key, "") - } + err := runWithUnlockRetry(handle, prof, args, "Backing up...", func(a []string) error { var err error - ok, code, err = doBackupOnce(key, mStatus, prof, args) + ok, code, err = doBackupOnce(handle, prof, a) return err }) if err != nil { return false, code } if ok { - setLastBackup(key, time.Now()) + handle.lastBackup(time.Now()) } return ok, code } -func runBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { +func runBackup(handle operationHandle, prof Profile) { if p, _ := findBackend(prof); p == "" { return } - if !acquireProfile(key) { - return - } - setProfileFailed(key, "") - defer onDone() - defer releaseProfile(key) - mStatus.SetTitle("Backing up...") - ok, code := doBackup(key, mStatus, prof, false) + handle.status("Backing up...") + ok, code := doBackup(handle, prof, false) if ok { if code == 3 { notifyError("Backup", "Completed with warnings: some files could not be read") @@ -266,7 +285,7 @@ func runBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func notifySuccess("Backup") } } else { - notifyError("Backup", getProfileFailStatus(key)) + notifyError("Backup", handle.failureOr("Backup failed")) } } @@ -285,12 +304,12 @@ func envWithBackendPath(env []string, prof Profile) []string { return append(env, "PATH="+dir) } -func hookCmd(hook string, prof Profile, extraEnv ...string) *exec.Cmd { +func hookCmdContext(ctx context.Context, hook string, prof Profile, extraEnv ...string) *exec.Cmd { var cmd *exec.Cmd if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", hook) + cmd = exec.CommandContext(ctx, "cmd", "/c", hook) } else { - cmd = exec.Command("sh", "-c", hook) + cmd = exec.CommandContext(ctx, "sh", "-c", hook) } cmd.Env = envWithBackendPath(profileEnv(prof), prof) cmd.Env = append(cmd.Env, extraEnv...) @@ -298,15 +317,26 @@ func hookCmd(hook string, prof Profile, extraEnv ...string) *exec.Cmd { return cmd } -func runHook(hook string, prof Profile, extraEnv ...string) error { +func runHookContext(ctx context.Context, started func(*os.Process), hook string, prof Profile, extraEnv ...string) error { if hook == "" { return nil } - cmd := hookCmd(hook, prof, extraEnv...) + cmd := hookCmdContext(ctx, hook, prof, extraEnv...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr log.Printf("[%s] running hook: %s", prof.displayName(), hook) - out, err := cmd.CombinedOutput() - if len(out) > 0 { - logPrefixedOutput(prof.displayName(), string(out)) + if err := cmd.Start(); err != nil { + log.Printf("[%s] hook %q failed to start: %v", prof.displayName(), hook, err) + return err + } + started(cmd.Process) + err := cmd.Wait() + if stdout.Len() > 0 { + logPrefixedOutput(prof.displayName(), stdout.String()) + } + if stderr.Len() > 0 { + logPrefixedOutput(prof.displayName(), stderr.String()) } if err != nil { log.Printf("[%s] hook %q failed: %v", prof.displayName(), hook, err) @@ -328,65 +358,13 @@ func scheduledOpNames(prof Profile) []string { return ops } -func runScheduledOperations( - prof Profile, - interrupted func() bool, - runBackup func() (bool, string), - runPrune func() (bool, string), - runCheck func() (bool, string), -) (bool, string) { - isInterrupted := func() bool { - return interrupted != nil && interrupted() - } - - failed := false - failMsg := "" - skipRemaining := false - - if !isInterrupted() && prof.Schedule.BackupEnabled() { - if ok, msg := runBackup(); !ok { - failed = true - failMsg = msg - skipRemaining = true - } - } - if !isInterrupted() && !skipRemaining && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { - if ok, msg := runPrune(); !ok { - failed = true - if failMsg == "" { - failMsg = msg - } - } - } - if !isInterrupted() && !skipRemaining && prof.Schedule.Check { - if ok, msg := runCheck(); !ok { - failed = true - if failMsg == "" { - failMsg = msg - } - } - } - - return failed, failMsg -} - -func runScheduled(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { +func runScheduled(handle operationHandle, prof Profile) { if p, _ := findBackend(prof); p == "" { return } - if !acquireProfile(key) { - return - } - defer onDone() - defer releaseProfile(key) - runScheduledAcquired(key, mStatus, prof) -} - -func runScheduledAcquired(key stateKey, mStatus prefixedMenuItem, prof Profile) { - setProfileFailed(key, "") if errMsg := prof.scheduleError(); errMsg != "" { - mStatus.SetTitle(errMsg) - setProfileFailed(key, errMsg) + handle.status(errMsg) + handle.fail(errMsg) notifyError("Schedule", errMsg) return } @@ -397,56 +375,56 @@ func runScheduledAcquired(key stateKey, mStatus prefixedMenuItem, prof Profile) } if prof.PreHook != "" { - mStatus.SetTitle("Running pre-hook...") - if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { - setProfileFailed(key, "Pre-hook failed") + handle.status("Running pre-hook...") + if err := runHookContext(handle.context(), handle.started, prof.PreHook, prof, hookEnv...); err != nil { + handle.fail("Pre-hook failed") notifyError("Schedule", "Pre-hook failed") return } } - failed, _ := runScheduledOperations( - prof, - nil, - func() (bool, string) { - mStatus.SetTitle("Backing up...") - if ok, _ := doBackup(key, mStatus, prof, true); !ok { - return false, getProfileFailStatus(key) - } - return true, "" - }, - func() (bool, string) { - mStatus.SetTitle("Pruning repository...") - if msg, err := runBackend(key, prof, mStatus, forgetArgs(prof)...); err != nil { - setProfileFailed(key, msg) - return false, msg - } - return true, "" - }, - func() (bool, string) { - mStatus.SetTitle("Checking repository...") - if msg, err := runBackend(key, prof, mStatus, checkArgs(prof)...); err != nil { - setProfileFailed(key, msg) - return false, msg - } - return true, "" - }, - ) + failed := false + isInterrupted := func() bool { return handle.context().Err() != nil } + if !isInterrupted() && prof.Schedule.BackupEnabled() { + handle.status("Backing up...") + if ok, _ := doBackup(handle, prof, true); !ok { + failed = true + } + } + if !isInterrupted() && !failed && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { + handle.status("Pruning repository...") + if msg, err := runBackend(handle, prof, forgetArgs(prof)...); err != nil { + handle.fail(msg) + failed = true + } + } + if !isInterrupted() && !failed && prof.Schedule.Check { + handle.status("Checking repository...") + if msg, err := runBackend(handle, prof, checkArgs(prof)...); err != nil { + handle.fail(msg) + failed = true + } + } - if failed { - notifyError("Schedule", getProfileFailStatus(key)) + interrupted := handle.context().Err() != nil + if interrupted { + handle.fail("Interrupted by user") + } + if failed || interrupted { + notifyError("Schedule", handle.failureOr("Schedule failed")) } else { notifySuccess("Schedule") } if prof.PostHook != "" { - mStatus.SetTitle("Running post-hook...") + handle.status("Running post-hook...") postEnv := hookEnv - if failed { - postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key)) + if failed || interrupted { + postEnv = append(postEnv, "RESTRAY_ERROR="+handle.failureOr("Schedule failed")) } - if err := runHook(prof.PostHook, prof, postEnv...); err != nil { - setProfileFailed(key, "Post-hook failed") + postContext := context.WithoutCancel(handle.context()) + if err := runHookContext(postContext, handle.started, prof.PostHook, prof, postEnv...); err != nil { + handle.fail("Post-hook failed") notifyError("Schedule", "Post-hook failed") } } @@ -486,110 +464,84 @@ func mountArgs(prof Profile, dir string) []string { return append(args, dir) } -func isProfileMounted(key stateKey) bool { - state.mu.Lock() - defer state.mu.Unlock() - return state.mountCmds[key] != nil -} - -func startMount(key stateKey, prof Profile, mMount *systray.MenuItem, onDone func()) { +func startMount(version uint64, key stateKey, generation uint64, prof Profile) { if !mountSupported(prof) { notifyError("Mount", backendUnavailable(prof, "mount")) - onDone() + return + } + handle, ok := state.beginMount(version, key, generation) + if !ok { return } dir, err := os.MkdirTemp("", "restray-mount-") if err != nil { notifyError("Mount", err.Error()) - onDone() + handle.done() return } - cmd := backendCmd(prof, mountArgs(prof, dir)...) - var stderr strings.Builder - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { + cmd := backendCmdContext(handle.context(), prof, mountArgs(prof, dir)...) + stderrPipe, err := startPipedBackend(handle, prof, cmd) + if err != nil { os.Remove(dir) notifyError("Mount", err.Error()) - onDone() + handle.done() return } - state.mu.Lock() - state.mountCmds[key] = cmd - state.mu.Unlock() - - mMount.SetTitle("Unmount") + stderrDone := make(chan string, 1) + go func() { + var sb strings.Builder + scanner := bufio.NewScanner(stderrPipe) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + logPrefixedLine(prof.displayName(), line) + sb.WriteString(line) + sb.WriteString("\n") + } + if err := scanner.Err(); err != nil { + log.Printf("[%s] reading command output: %v", prof.displayName(), err) + _, _ = io.Copy(io.Discard, stderrPipe) + } + stderrDone <- sb.String() + }() done := make(chan error, 1) go func() { done <- cmd.Wait() }() mounted := false ticker := time.NewTicker(250 * time.Millisecond) - defer ticker.Stop() timeout := time.NewTimer(30 * time.Second) - defer timeout.Stop() -wait: +loop: for { select { case <-done: - break wait + break loop case <-timeout.C: - interruptProcess(cmd.Process) + state.cancelMount(key) <-done - break wait + break loop case <-ticker.C: - entries, _ := os.ReadDir(dir) - if len(entries) > 0 { + if entries, _ := os.ReadDir(dir); len(entries) > 0 { mounted = true - break wait + break loop } } } + ticker.Stop() + timeout.Stop() if mounted { openFile(dir) <-done } - state.mu.Lock() - delete(state.mountCmds, key) - stopped := state.mountStopping[key] - delete(state.mountStopping, key) - state.mu.Unlock() + stopped := handle.done() os.Remove(dir) - if msg := stderr.String(); msg != "" && !stopped { + if msg := <-stderrDone; msg != "" && !stopped { notifyError("Mount", strings.TrimSpace(msg)) } - - onDone() -} - -func stopProfileMount(key stateKey) { - state.mu.Lock() - defer state.mu.Unlock() - if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil { - state.mountStopping[key] = true - interruptProcess(cmd.Process) - } -} - -func stopAllMounts() { - state.mu.Lock() - defer state.mu.Unlock() - for key, cmd := range state.mountCmds { - if cmd != nil && cmd.Process != nil { - state.mountStopping[key] = true - interruptProcess(cmd.Process) - } - } -} - -func notifyMode() string { - state.mu.Lock() - defer state.mu.Unlock() - return state.notifications } func notifyError(operation, msg string) { diff --git a/cmd/restray/operations_test.go b/cmd/restray/operations_test.go index 27cff16..160b04c 100644 --- a/cmd/restray/operations_test.go +++ b/cmd/restray/operations_test.go @@ -40,28 +40,3 @@ func TestBackupArgs(t *testing.T) { t.Fatalf("rustic backupArgs() = %#v, want %#v", got, want) } } - -func TestRunScheduledOperations(t *testing.T) { - prof := Profile{Schedule: Schedule{Prune: true, Check: true}, Prune: OperationOptions{Args: []string{"--keep-last", "4"}}} - var ran []string - failed, msg := runScheduledOperations(prof, nil, - func() (bool, string) { ran = append(ran, "backup"); return false, "backup failed" }, - func() (bool, string) { ran = append(ran, "prune"); return true, "" }, - func() (bool, string) { ran = append(ran, "check"); return true, "" }, - ) - if !failed || msg != "backup failed" || !reflect.DeepEqual(ran, []string{"backup"}) { - t.Fatalf("backup failure: failed=%v msg=%q ran=%v", failed, msg, ran) - } - - ran = nil - disabled := false - prof.Schedule.Backup = &disabled - failed, msg = runScheduledOperations(prof, nil, - func() (bool, string) { ran = append(ran, "backup"); return true, "" }, - func() (bool, string) { ran = append(ran, "prune"); return false, "prune failed" }, - func() (bool, string) { ran = append(ran, "check"); return false, "check failed" }, - ) - if !failed || msg != "prune failed" || !reflect.DeepEqual(ran, []string{"prune", "check"}) { - t.Fatalf("maintenance failure: failed=%v msg=%q ran=%v", failed, msg, ran) - } -} diff --git a/cmd/restray/repository.go b/cmd/restray/repository.go index 6861eba..37a403a 100644 --- a/cmd/restray/repository.go +++ b/cmd/restray/repository.go @@ -44,7 +44,7 @@ func probeRepo(prof Profile, args []string) ([]byte, repoResult) { defer cancel() args = backendCommandArgs(prof, args...) path, _ := findBackend(prof) - log.Printf("[%s] probe (%s): %s %s", prof.displayName(), prof.backendName(), prof.backendName(), strings.Join(args, " ")) + log.Printf("[%s] probe: %s %s", prof.displayName(), prof.backendName(), strings.Join(args, " ")) start := time.Now() cmd := exec.CommandContext(ctx, path, args...) cmd.Env = profileEnv(prof) @@ -61,7 +61,14 @@ func probeRepo(prof Profile, args []string) ([]byte, repoResult) { log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) lines := strings.Split(trimmed, "\n") msg, code := classifyBackendError(prof, err, lines[len(lines)-1]) - needsInit := repositoryNeedsInit(prof, code, trimmed) + var needsInit bool + switch prof.backend() { + case BackendRestic: + needsInit = code == 10 || (code == 1 && strings.Contains(strings.ToLower(trimmed), "repository does not exist")) + case BackendRustic: + s := strings.ToLower(trimmed) + needsInit = strings.Contains(s, "repository does not exist") || strings.Contains(s, "repository is not initialized") || strings.Contains(s, "no repository config file found") || (strings.Contains(s, "no such file or directory") && strings.Contains(s, "config")) + } if needsInit { msg = "Repository not initialized" } @@ -185,22 +192,6 @@ func classifyResticError(err error, lastStderr string) (string, int) { return "", code } -func rusticNeedsInit(output string) bool { - s := strings.ToLower(output) - return strings.Contains(s, "repository does not exist") || strings.Contains(s, "repository is not initialized") || strings.Contains(s, "no repository config file found") || strings.Contains(s, "no such file or directory") && strings.Contains(s, "config") -} - -func repositoryNeedsInit(prof Profile, code int, output string) bool { - switch prof.backend() { - case BackendRestic: - return code == 10 || (code == 1 && strings.Contains(strings.ToLower(output), "repository does not exist")) - case BackendRustic: - return rusticNeedsInit(output) - default: - return false - } -} - func classifyBackendError(prof Profile, err error, lastStderr string) (string, int) { if prof.backend() == BackendRestic { return classifyResticError(err, lastStderr) diff --git a/cmd/restray/repository_test.go b/cmd/restray/repository_test.go index 1d3e768..a06cbfe 100644 --- a/cmd/restray/repository_test.go +++ b/cmd/restray/repository_test.go @@ -5,54 +5,6 @@ import ( "testing" ) -func TestRepositoryUnreachable(t *testing.T) { - tests := []struct { - name string - err error - stderr string - want bool - }{ - {name: "dns failure", err: errors.New("exit status 1"), stderr: "lookup backup.example: no such host", want: true}, - {name: "connection refused", err: errors.New("exit status 1"), stderr: "dial tcp 127.0.0.1:9000: connect: connection refused", want: true}, - {name: "timeout", err: errors.New("exit status 1"), stderr: "dial tcp: i/o timeout", want: true}, - {name: "wrong password", err: errors.New("exit status 1"), stderr: "wrong password", want: false}, - {name: "not initialized", err: errors.New("exit status 1"), stderr: "repository does not exist", want: false}, - {name: "permission denied", err: errors.New("exit status 1"), stderr: "permission denied", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := repositoryUnreachable(tt.err, tt.stderr); got != tt.want { - t.Fatalf("repositoryUnreachable() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestRepositoryNeedsInit(t *testing.T) { - tests := []struct { - name string - profile Profile - code int - output string - want bool - }{ - {"restic exit code", Profile{Backend: "restic"}, 10, "", true}, - {"restic older exit code", Profile{Backend: "restic"}, 1, "repository does not exist", true}, - {"restic wrong password", Profile{Backend: "restic"}, 1, "wrong password", false}, - {"rustic missing repository", Profile{Backend: "rustic"}, 1, "Repository is not initialized", true}, - {"rustic config missing", Profile{Backend: "rustic"}, 1, "no such file or directory: config", true}, - {"rustic auth error", Profile{Backend: "rustic"}, 1, "password failed", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := repositoryNeedsInit(tt.profile, tt.code, tt.output); got != tt.want { - t.Fatalf("repositoryNeedsInit() = %v, want %v", got, tt.want) - } - }) - } -} - func TestClassifyResticError(t *testing.T) { tests := []struct { name string diff --git a/cmd/restray/schedule.go b/cmd/restray/schedule.go index 3ce7afb..c20e98d 100644 --- a/cmd/restray/schedule.go +++ b/cmd/restray/schedule.go @@ -1,6 +1,7 @@ package main import ( + "errors" "log" "strings" @@ -15,54 +16,45 @@ func describeCron(expr string) string { if err != nil { return expr } - desc = strings.TrimSpace(desc) - if len(desc) > 0 { - desc = strings.ToLower(desc[:1]) + desc[1:] + if desc = strings.TrimSpace(desc); desc == "" { + return expr } - return desc + return strings.ToLower(desc[:1]) + desc[1:] } func onBatteryPower() bool { b, err := battery.Get(0) - if _, ok := err.(battery.ErrFatal); ok { - return false - } - if b == nil { + var fatal battery.ErrFatal + if errors.As(err, &fatal) || b == nil { return false } return b.State.Raw == battery.Discharging } -func schedulePausedForBattery(prof Profile) bool { - return !prof.Schedule.OnBattery && onBatteryPower() -} - -func runScheduleCallback(key stateKey, onDone func(), run func(Config, int, Profile)) { - name := string(key) +func runScheduleCallback(key stateKey, run func(Profile)) { + id := string(key) defer func() { if r := recover(); r != nil { - log.Printf("[%s] scheduled run panicked: %v", name, r) + log.Printf("[%s] scheduled run panicked: %v", id, r) } }() cfg := loadConfig() - idx, prof, err := resolveProfileIndex(cfg, key) + _, prof, err := resolveProfileIndex(cfg, key) if err != nil { - log.Printf("[%s] skipping scheduled run: %v", name, err) + log.Printf("[%s] skipping scheduled run: %v", id, err) return } - name = prof.displayName() - if schedulePausedForBattery(prof) { + name := prof.displayName() + if !prof.Schedule.OnBattery && onBatteryPower() { log.Printf("[%s] skipping scheduled run: on battery power", name) return } - if !acquireProfile(key) { + handle, ok := state.beginOperation(0, key, 0) + if !ok { log.Printf("[%s] skipping scheduled run: profile already busy", name) return } - if onDone != nil { - defer onDone() - } - defer releaseProfile(key) - run(cfg, idx, prof) + defer handle.done() + run(prof) } diff --git a/cmd/restray/schedule_test.go b/cmd/restray/schedule_test.go index 573222a..d681b5c 100644 --- a/cmd/restray/schedule_test.go +++ b/cmd/restray/schedule_test.go @@ -5,7 +5,14 @@ import ( "testing" ) -func writeScheduleConfig(t *testing.T, contents string) { +func isolateGlobalState(t *testing.T) { + t.Helper() + old := state + state = newAppState() + t.Cleanup(func() { state = old }) +} + +func setTestConfig(t *testing.T, contents string) { t.Helper() oldConfigDir := configDirOverride configDirOverride = t.TempDir() @@ -15,106 +22,107 @@ func writeScheduleConfig(t *testing.T, contents string) { } } -func setupScheduleCallbackTest(t *testing.T) stateKey { - t.Helper() - writeScheduleConfig(t, "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\non_battery = true\nbackup = false\n") - resetBusyState(t) - return stateKey("scheduled") -} - func TestRunScheduleCallback(t *testing.T) { - key := setupScheduleCallbackTest(t) + isolateGlobalState(t) + setTestConfig(t, ` +[[profiles]] +name = 'scheduled' + +[profiles.schedule] +on_battery = true +backup = false +`) + key := stateKey("scheduled") var ran bool - doneAfterRelease := false - runScheduleCallback(key, func() { - doneAfterRelease = !isProfileBusy(key) - }, func(_ Config, _ int, prof Profile) { + runScheduleCallback(key, func(prof Profile) { ran = prof.profileKey() == key - if !isProfileBusy(key) { + if probe, ok := state.beginOperation(0, key, 0); ok { + probe.done() t.Error("profile was not busy during callback") } }) if !ran { t.Fatal("schedule callback did not run") } - if !doneAfterRelease { - t.Fatal("completion callback ran before profile release") + if probe, ok := state.beginOperation(0, key, 0); !ok { + t.Fatal("profile remained busy after callback") + } else { + probe.done() } -} -func TestRunScheduleCallbackGuards(t *testing.T) { t.Run("missing profile", func(t *testing.T) { - setupScheduleCallbackTest(t) ran := false - runScheduleCallback("missing", nil, func(Config, int, Profile) { ran = true }) + runScheduleCallback("missing", func(Profile) { ran = true }) if ran { t.Fatal("callback ran for missing profile") } }) - - t.Run("busy", func(t *testing.T) { - key := setupScheduleCallbackTest(t) - if !acquireProfile(key) { - t.Fatal("could not mark profile busy") - } - defer releaseProfile(key) - ran := false - runScheduleCallback(key, nil, func(Config, int, Profile) { ran = true }) - if ran { - t.Fatal("callback ran for busy profile") - } - if !isProfileBusy(key) { - t.Fatal("callback released another operation's profile ownership") - } - }) } func TestBuildDaemonScheduleValidation(t *testing.T) { tests := []struct { - name string - config string - wantErr bool + name string + config string + wantErr bool + wantEntries int }{ { - name: "missing environment", - config: "[[profiles]]\nname = 'scheduled'\nenv_file = '/missing/profile.env'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", + name: "missing environment", + config: ` +[[profiles]] +name = 'scheduled' +env_file = '/missing/profile.env' + +[profiles.schedule] +cron = '0 * * * *' + +[profiles.backup] +paths = ['/data'] +`, + wantEntries: 1, }, { - name: "unsupported backend", - config: "[[profiles]]\nname = 'scheduled'\nbackend = 'borg'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", + name: "unsupported backend", + config: ` +[[profiles]] +name = 'scheduled' +backend = 'borg' + +[profiles.schedule] +cron = '0 * * * *' + +[profiles.backup] +paths = ['/data'] +`, wantErr: true, }, { - name: "missing backup paths", - config: "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\ncron = '0 * * * *'\n", + name: "missing backup paths", + config: ` +[[profiles]] +name = 'scheduled' + +[profiles.schedule] +cron = '0 * * * *' +`, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - writeScheduleConfig(t, tt.config) + setTestConfig(t, tt.config) sched, err := buildDaemonSchedule() if (err != nil) != tt.wantErr { t.Fatalf("buildDaemonSchedule() error = %v, wantErr %v", err, tt.wantErr) } - if !tt.wantErr && len(sched.Entries()) != 1 { - t.Fatalf("schedule entries = %d, want 1", len(sched.Entries())) + if tt.wantErr { + return + } + t.Cleanup(func() { sched.Stop() }) + if got := len(sched.Entries()); got != tt.wantEntries { + t.Fatalf("schedule entries = %d, want %d", got, tt.wantEntries) } }) } } - -func TestRunScheduleCallbackPanicCleanup(t *testing.T) { - key := setupScheduleCallbackTest(t) - done := false - runScheduleCallback(key, func() { done = true }, func(Config, int, Profile) { - panic("boom") - }) - if isProfileBusy(key) { - t.Fatal("profile remained busy after panic") - } - if !done { - t.Fatal("completion callback did not run after panic") - } -} diff --git a/cmd/restray/state.go b/cmd/restray/state.go index 4d1a962..77feef7 100644 --- a/cmd/restray/state.go +++ b/cmd/restray/state.go @@ -1,7 +1,9 @@ package main import ( - "os/exec" + "context" + "os" + "slices" "sync" "time" @@ -16,158 +18,671 @@ type profileState struct { unreachable bool scheduleErr string needsInit bool + mountable bool + probing bool entryID cron.EntryID } +type profileSnapshot struct { + generation uint64 + state profileState + failure string + lastBackup time.Time + operationStatus string + operationActive bool + mounted bool + mountStopping bool +} + +type appSnapshot struct { + cfg Config + configVersion uint64 + activeProfile stateKey + profiles map[stateKey]profileSnapshot + busy bool + batteryOn bool + globalStatus string + globalStatusErr bool + backendMissing string + backendUpdate string + appUpdate string + downloading bool +} + +type profileRecord struct { + profile Profile + generation uint64 + state profileState + failure string + lastBackup time.Time + probeID uint64 +} + +type operationRecord struct { + id uint64 + status string + generation uint64 + cancel context.CancelFunc + process *os.Process +} + +type operationHandle struct { + store *appState + key stateKey + id uint64 + generation uint64 + ctx context.Context +} + +type mountRecord struct { + id uint64 + process *os.Process + cancel context.CancelFunc + stopping bool +} + +type mountHandle struct { + store *appState + key stateKey + id uint64 + ctx context.Context +} + type appState struct { - mu sync.Mutex - busyProfiles map[stateKey]*exec.Cmd - onBusyChanged func(bool) - mountCmds map[stateKey]*exec.Cmd - mountStopping map[stateKey]bool - notifications string - failStatus map[stateKey]string - lastBackup map[stateKey]time.Time + mu sync.RWMutex + + cfg Config + configVersion uint64 activeProfile stateKey - profileStates map[stateKey]profileState + profiles map[stateKey]profileRecord + operations map[stateKey]*operationRecord + mounts map[stateKey]*mountRecord + + nextID uint64 + + batteryOn bool + globalStatus string + globalStatusErr bool + backendMissing string + backendUpdate string + appUpdate string + + downloadID uint64 + downloadKey stateKey + downloadGeneration uint64 + downloading bool + + changes chan struct{} + statusChanges chan struct{} +} + +func newAppState() *appState { + return &appState{ + profiles: make(map[stateKey]profileRecord), + operations: make(map[stateKey]*operationRecord), + mounts: make(map[stateKey]*mountRecord), + changes: make(chan struct{}, 1), + statusChanges: make(chan struct{}, 1), + } } -var state = appState{ - busyProfiles: make(map[stateKey]*exec.Cmd), - mountCmds: make(map[stateKey]*exec.Cmd), - mountStopping: make(map[stateKey]bool), - failStatus: make(map[stateKey]string), - lastBackup: make(map[stateKey]time.Time), - profileStates: make(map[stateKey]profileState), +var state = newAppState() + +func (s *appState) signal() { + select { + case s.changes <- struct{}{}: + default: + } } -func resetProfileState(keys []stateKey) stateKey { - state.mu.Lock() - defer state.mu.Unlock() +func (s *appState) Changes() <-chan struct{} { return s.changes } - profiles := make(map[stateKey]profileState, len(keys)) - for _, key := range keys { - profiles[key] = state.profileStates[key] +func (s *appState) StatusChanges() <-chan struct{} { return s.statusChanges } + +func (s *appState) signalStatus() { + select { + case s.statusChanges <- struct{}{}: + default: + } +} + +func profilesEqual(a, b Profile) bool { + if a.Name != b.Name || a.Backend != b.Backend || a.EnvFile != b.EnvFile || + a.RcloneConfigFile != b.RcloneConfigFile || a.RetryLock != b.RetryLock || + a.PreHook != b.PreHook || a.PostHook != b.PostHook { + return false + } + if a.Schedule.Cron != b.Schedule.Cron || a.Schedule.OnBattery != b.Schedule.OnBattery || + a.Schedule.Prune != b.Schedule.Prune || a.Schedule.Check != b.Schedule.Check { + return false } - state.profileStates = profiles - if _, ok := profiles[state.activeProfile]; !ok { - state.activeProfile = "" - if len(keys) > 0 { - state.activeProfile = keys[0] + if (a.Schedule.Backup == nil) != (b.Schedule.Backup == nil) { + return false + } + if a.Schedule.Backup != nil && *a.Schedule.Backup != *b.Schedule.Backup { + return false + } + return slices.Equal(a.Backup.Paths, b.Backup.Paths) && + slices.Equal(a.Backup.Args, b.Backup.Args) && + slices.Equal(a.Backup.ArgsScheduled, b.Backup.ArgsScheduled) && + slices.Equal(a.Prune.Args, b.Prune.Args) && + slices.Equal(a.Check.Args, b.Check.Args) && + slices.Equal(a.Mount.Args, b.Mount.Args) +} + +func (s *appState) snapshot() appSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + snap := appSnapshot{ + cfg: s.cfg, + configVersion: s.configVersion, + activeProfile: s.activeProfile, + profiles: make(map[stateKey]profileSnapshot, len(s.profiles)), + busy: len(s.operations) > 0, + batteryOn: s.batteryOn, + globalStatus: s.globalStatus, + globalStatusErr: s.globalStatusErr, + backendMissing: s.backendMissing, + backendUpdate: s.backendUpdate, + appUpdate: s.appUpdate, + downloading: s.downloading, + } + for key, profile := range s.profiles { + mount, mounted := s.mounts[key] + stopping := mounted && mount.stopping + item := profileSnapshot{ + generation: profile.generation, + state: profile.state, + failure: profile.failure, + lastBackup: profile.lastBackup, + mounted: mounted && !stopping, + mountStopping: stopping, + } + if operation := s.operations[key]; operation != nil { + item.operationActive = true + if operation.generation == profile.generation { + item.operationStatus = operation.status + } } + snap.profiles[key] = item } - return state.activeProfile + return snap } -func activeProfileKey() stateKey { - state.mu.Lock() - defer state.mu.Unlock() - return state.activeProfile +func (s *appState) replaceConfig(cfg Config) uint64 { + s.mu.Lock() + + s.configVersion++ + oldStatus := s.globalStatus + oldStatusErr := s.globalStatusErr + oldMissing := s.backendMissing + oldDownloading := s.downloading + oldProfiles := s.profiles + profiles := make(map[stateKey]profileRecord, len(cfg.Profiles)) + for _, prof := range cfg.Profiles { + key := prof.profileKey() + old, exists := oldProfiles[key] + if !exists || !profilesEqual(old.profile, prof) { + s.nextID++ + old = profileRecord{ + profile: prof, + generation: s.nextID, + state: profileState{probing: true}, + } + } else { + old.profile = prof + old.probeID = 0 + old.state.entryID = 0 + old.state.probing = true + old.state.mountable = false + } + profiles[key] = old + } + var obsoleteMounts []*mountRecord + for key, mount := range s.mounts { + old, oldExists := oldProfiles[key] + next, nextExists := profiles[key] + if !mount.stopping && (!oldExists || !nextExists || old.generation != next.generation) { + mount.stopping = true + obsoleteMounts = append(obsoleteMounts, mount) + } + } + s.cfg = cfg + s.profiles = profiles + if _, ok := profiles[s.activeProfile]; !ok { + s.activeProfile = "" + if len(cfg.Profiles) > 0 { + s.activeProfile = cfg.Profiles[0].profileKey() + } + } + s.globalStatus = "" + s.globalStatusErr = false + s.backendMissing = "" + s.backendUpdate = "" + if oldDownloading { + s.globalStatus = oldStatus + s.globalStatusErr = oldStatusErr + s.backendMissing = oldMissing + } + if !cfg.GUI.UpdatesEnabled() { + s.appUpdate = "" + } + version := s.configVersion + s.mu.Unlock() + for _, mount := range obsoleteMounts { + interrupt(mount.cancel, mount.process) + } + s.signal() + return version } -func setActiveProfileKey(key stateKey) { - state.mu.Lock() - if _, ok := state.profileStates[key]; ok { - state.activeProfile = key +func interrupt(cancel context.CancelFunc, process *os.Process) { + if cancel != nil { + cancel() + } + if process != nil { + interruptProcess(process) } - state.mu.Unlock() } -func getProfileState(key stateKey) (profileState, bool) { - state.mu.Lock() - defer state.mu.Unlock() - ps, ok := state.profileStates[key] - return ps, ok +func (s *appState) setActiveProfile(key stateKey) { + s.mu.Lock() + if _, ok := s.profiles[key]; !ok { + s.mu.Unlock() + return + } + if s.activeProfile == key { + s.mu.Unlock() + return + } + s.activeProfile = key + s.backendMissing = "" + s.backendUpdate = "" + if !s.downloading { + s.globalStatus = "" + s.globalStatusErr = false + } + s.mu.Unlock() + s.signal() } -func setProfileProbeState(key stateKey, probe profileState) { - state.mu.Lock() - if current, ok := state.profileStates[key]; ok { - probe.entryID = current.entryID - state.profileStates[key] = probe +func (s *appState) setBattery(on bool) { + s.mu.Lock() + if s.batteryOn == on { + s.mu.Unlock() + return } - state.mu.Unlock() + s.batteryOn = on + s.mu.Unlock() + s.signal() } -func setProfileCronEntry(key stateKey, entryID cron.EntryID) { - state.mu.Lock() - if current, ok := state.profileStates[key]; ok { - current.entryID = entryID - state.profileStates[key] = current +func (s *appState) beginProbe(key stateKey, version, generation uint64) (uint64, bool) { + s.mu.Lock() + profile, ok := s.profiles[key] + valid := ok && s.configVersion == version && profile.generation == generation && profile.probeID == 0 && s.operations[key] == nil && !s.downloading + var id uint64 + if valid { + s.nextID++ + profile.probeID = s.nextID + id = profile.probeID + profile.state = profileState{probing: true, entryID: profile.state.entryID} + s.profiles[key] = profile } - state.mu.Unlock() + s.mu.Unlock() + if valid { + s.signal() + } + return id, valid } -func setLastBackup(key stateKey, t time.Time) { - state.mu.Lock() - state.lastBackup[key] = t - state.mu.Unlock() +func (s *appState) finishProbe(key stateKey, version, generation, probeID uint64, probe profileState, lastBackup time.Time) bool { + s.mu.Lock() + profile, ok := s.profiles[key] + valid := ok && s.configVersion == version && profile.generation == generation && probeID != 0 && profile.probeID == probeID && s.operations[key] == nil + if valid { + probe.entryID = profile.state.entryID + probe.probing = false + profile.state = probe + profile.probeID = 0 + if !lastBackup.IsZero() { + profile.lastBackup = lastBackup + } + s.profiles[key] = profile + } + s.mu.Unlock() + if valid { + s.signal() + } + return valid } -func getLastBackup(key stateKey) time.Time { - state.mu.Lock() - defer state.mu.Unlock() - return state.lastBackup[key] +func (s *appState) updateProfile(key stateKey, version, generation uint64, fn func(*profileRecord)) bool { + s.mu.Lock() + profile, ok := s.profiles[key] + valid := ok && s.configVersion == version && profile.generation == generation + if valid { + fn(&profile) + s.profiles[key] = profile + } + s.mu.Unlock() + if valid { + s.signal() + } + return valid } -func isProfileBusy(key stateKey) bool { - state.mu.Lock() - defer state.mu.Unlock() - _, ok := state.busyProfiles[key] - return ok +func (s *appState) operationProfileLocked(handle operationHandle) (profileRecord, bool) { + operation, ok := s.operations[handle.key] + if !ok || operation.id != handle.id { + return profileRecord{}, false + } + profile, exists := s.profiles[handle.key] + if !exists || profile.generation != handle.generation { + return profileRecord{}, false + } + return profile, true } -func isAnyBusy() bool { - state.mu.Lock() - defer state.mu.Unlock() - return len(state.busyProfiles) > 0 +func (s *appState) setOperationFailure(handle operationHandle, status string) { + s.mu.Lock() + profile, valid := s.operationProfileLocked(handle) + if valid { + profile.failure = status + s.profiles[handle.key] = profile + } + s.mu.Unlock() } -func releaseProfile(key stateKey) { - state.mu.Lock() - wasBusy := len(state.busyProfiles) > 0 - delete(state.busyProfiles, key) - nowBusy := len(state.busyProfiles) > 0 - cb := state.onBusyChanged - state.mu.Unlock() - finishBusyChange(wasBusy, nowBusy, cb) +func (s *appState) operationFailure(handle operationHandle) string { + s.mu.RLock() + defer s.mu.RUnlock() + profile, valid := s.operationProfileLocked(handle) + if !valid { + return "" + } + return profile.failure +} + +func (s *appState) setOperationLastBackup(handle operationHandle, when time.Time) { + s.mu.Lock() + profile, valid := s.operationProfileLocked(handle) + if valid { + profile.lastBackup = when + s.profiles[handle.key] = profile + } + s.mu.Unlock() +} + +func (s *appState) beginOperation(version uint64, key stateKey, generation uint64) (operationHandle, bool) { + s.mu.Lock() + if s.downloading { + s.mu.Unlock() + return operationHandle{}, false + } + if _, busy := s.operations[key]; busy { + s.mu.Unlock() + return operationHandle{}, false + } + profile, exists := s.profiles[key] + if version != 0 && (!exists || s.configVersion != version || profile.generation != generation) { + s.mu.Unlock() + return operationHandle{}, false + } + if exists && profile.probeID != 0 { + s.mu.Unlock() + return operationHandle{}, false + } + if exists { + profile.failure = "" + s.profiles[key] = profile + } + + s.nextID++ + ctx, cancel := context.WithCancel(context.Background()) + operation := &operationRecord{ + id: s.nextID, + generation: profile.generation, + cancel: cancel, + } + s.operations[key] = operation + s.mu.Unlock() + s.signal() + return operationHandle{ + store: s, + key: key, + id: operation.id, + generation: operation.generation, + ctx: ctx, + }, true +} + +func (s *appState) endOperation(handle operationHandle) { + s.mu.Lock() + operation, ok := s.operations[handle.key] + if ok && operation.id == handle.id { + delete(s.operations, handle.key) + cancel := operation.cancel + s.mu.Unlock() + cancel() + s.signal() + return + } + s.mu.Unlock() +} + +func (s *appState) setOperationStatus(handle operationHandle, status string) { + s.mu.Lock() + operation, ok := s.operations[handle.key] + valid := ok && operation.id == handle.id + changed := valid && operation.status != status + if changed { + operation.status = status + } + s.mu.Unlock() + if changed { + s.signalStatus() + } +} + +func (s *appState) setOperationProcess(handle operationHandle, process *os.Process) { + s.mu.Lock() + if operation, ok := s.operations[handle.key]; ok && operation.id == handle.id { + operation.process = process + } + s.mu.Unlock() +} + +func (s *appState) cancelOperation(key stateKey) { + s.mu.RLock() + operation := s.operations[key] + var cancel context.CancelFunc + var process *os.Process + if operation != nil { + cancel = operation.cancel + process = operation.process + } + s.mu.RUnlock() + interrupt(cancel, process) +} + +func (s *appState) beginMount(version uint64, key stateKey, generation uint64) (mountHandle, bool) { + s.mu.Lock() + profile, current := s.profiles[key] + if s.downloading || s.configVersion != version || s.activeProfile != key || !current || profile.generation != generation || profile.probeID != 0 { + s.mu.Unlock() + return mountHandle{}, false + } + if _, mounted := s.mounts[key]; mounted { + s.mu.Unlock() + return mountHandle{}, false + } + s.nextID++ + ctx, cancel := context.WithCancel(context.Background()) + s.mounts[key] = &mountRecord{id: s.nextID, cancel: cancel} + s.mu.Unlock() + s.signal() + return mountHandle{store: s, key: key, id: s.nextID, ctx: ctx}, true +} + +func (s *appState) setMountProcess(handle mountHandle, process *os.Process) { + s.mu.Lock() + if mount, ok := s.mounts[handle.key]; ok && mount.id == handle.id { + mount.process = process + } + s.mu.Unlock() } -func acquireProfile(key stateKey) bool { - state.mu.Lock() - if _, busy := state.busyProfiles[key]; busy { - state.mu.Unlock() +func (s *appState) finishMount(handle mountHandle) bool { + s.mu.Lock() + mount, ok := s.mounts[handle.key] + if !ok || mount.id != handle.id { + s.mu.Unlock() return false } - wasBusy := len(state.busyProfiles) > 0 - state.busyProfiles[key] = nil - nowBusy := len(state.busyProfiles) > 0 - cb := state.onBusyChanged - state.mu.Unlock() - finishBusyChange(wasBusy, nowBusy, cb) - return true + delete(s.mounts, handle.key) + cancel := mount.cancel + stopping := mount.stopping + s.mu.Unlock() + cancel() + s.signal() + return stopping +} + +func (s *appState) cancelMount(key stateKey) { + s.mu.Lock() + mount, ok := s.mounts[key] + if !ok { + s.mu.Unlock() + return + } + mount.stopping = true + cancel := mount.cancel + process := mount.process + s.mu.Unlock() + interrupt(cancel, process) + s.signal() } -func finishBusyChange(wasBusy, nowBusy bool, cb func(bool)) { - if nowBusy && !wasBusy { - setIconAnimated("busy") +func (s *appState) cancelAllMounts() { + s.mu.Lock() + mounts := make([]*mountRecord, 0, len(s.mounts)) + for _, mount := range s.mounts { + mount.stopping = true + mounts = append(mounts, mount) + } + s.mu.Unlock() + for _, mount := range mounts { + interrupt(mount.cancel, mount.process) } - if cb != nil { - cb(nowBusy) + if len(mounts) > 0 { + s.signal() + } +} + +func (s *appState) beginDownload(version uint64, key stateKey, generation uint64, label string) (uint64, bool) { + s.mu.Lock() + profile, current := s.profiles[key] + if s.downloading || len(s.operations) > 0 || len(s.mounts) > 0 || s.configVersion != version || s.activeProfile != key || !current || profile.generation != generation { + s.mu.Unlock() + return 0, false } + s.downloadID++ + s.downloadKey = key + s.downloadGeneration = generation + s.downloading = true + s.globalStatus = "Downloading " + label + "..." + s.globalStatusErr = false + s.backendUpdate = "" + id := s.downloadID + s.mu.Unlock() + s.signal() + return id, true } -func setProfileBusyCmd(key stateKey, cmd *exec.Cmd) { - state.mu.Lock() - defer state.mu.Unlock() - state.busyProfiles[key] = cmd +func (s *appState) updateDownload(id uint64, status string) { + s.mu.Lock() + valid := s.downloading && s.downloadID == id + if valid { + s.globalStatus = status + } + s.mu.Unlock() + if valid { + s.signal() + } } -func cancelProfile(key stateKey) { - state.mu.Lock() - cmd := state.busyProfiles[key] - state.mu.Unlock() - if cmd != nil && cmd.Process != nil { - interruptProcess(cmd.Process) +func (s *appState) finishDownload(id uint64, err error) bool { + s.mu.Lock() + valid := s.downloading && s.downloadID == id + if valid { + profile, current := s.profiles[s.downloadKey] + stillCurrent := current && profile.generation == s.downloadGeneration && s.activeProfile == s.downloadKey + s.downloading = false + s.downloadKey = "" + s.downloadGeneration = 0 + if !stillCurrent { + s.globalStatus = "" + s.globalStatusErr = false + } else if err != nil { + s.globalStatus = "Download failed: " + err.Error() + s.globalStatusErr = true + } else { + s.globalStatus = "" + s.globalStatusErr = false + s.backendMissing = "" + } } + s.mu.Unlock() + if valid { + s.signal() + } + return valid } + +func (s *appState) mutateIfCurrent(version uint64, key stateKey, fn func()) bool { + s.mu.Lock() + valid := s.configVersion == version && s.activeProfile == key + if valid { + fn() + } + s.mu.Unlock() + if valid { + s.signal() + } + return valid +} + +func (s *appState) setAppUpdateIfCurrent(version uint64, status string) bool { + s.mu.Lock() + valid := s.configVersion == version && s.cfg.GUI.UpdatesEnabled() + if valid { + s.appUpdate = status + } + s.mu.Unlock() + if valid { + s.signal() + } + return valid +} + +func (h operationHandle) status(status string) { h.store.setOperationStatus(h, status) } + +func (h operationHandle) fail(status string) { h.store.setOperationFailure(h, status) } + +func (h operationHandle) failure() string { return h.store.operationFailure(h) } + +func (h operationHandle) lastBackup(when time.Time) { h.store.setOperationLastBackup(h, when) } + +func (h operationHandle) started(process *os.Process) { h.store.setOperationProcess(h, process) } + +func (h operationHandle) done() { h.store.endOperation(h) } + +func (h operationHandle) context() context.Context { return h.ctx } + +func (h mountHandle) started(process *os.Process) { h.store.setMountProcess(h, process) } + +func (h mountHandle) done() bool { return h.store.finishMount(h) } + +func (h mountHandle) context() context.Context { return h.ctx } diff --git a/cmd/restray/state_test.go b/cmd/restray/state_test.go index 87ee8a1..b86cc4a 100644 --- a/cmd/restray/state_test.go +++ b/cmd/restray/state_test.go @@ -1,149 +1,281 @@ package main import ( - "os" - "os/exec" "testing" "time" ) -const busyTransitionGuard stateKey = "busy-transition-guard" - -func resetProfileStateForTest(t *testing.T) { +func mustSingleProfile(t *testing.T) (*appState, stateKey, uint64, uint64) { t.Helper() - state.mu.Lock() - oldActive := state.activeProfile - oldProfiles := state.profileStates - state.activeProfile = "" - state.profileStates = make(map[stateKey]profileState) - state.mu.Unlock() - t.Cleanup(func() { - state.mu.Lock() - state.activeProfile = oldActive - state.profileStates = oldProfiles - state.mu.Unlock() - }) + s := newAppState() + key := stateKey("alpha") + version := s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha"}}}) + return s, key, version, s.snapshot().profiles[key].generation } -func TestProfileStatePartialUpdates(t *testing.T) { - resetProfileStateForTest(t) - key := stateKey("alpha") - resetProfileState([]stateKey{key}) - setProfileCronEntry(key, 42) - want := profileState{errMsg: "config", repoErr: "repository", scheduleErr: "schedule", needsInit: true, entryID: 42} - setProfileProbeState(key, want) +func mustBlock(t *testing.T, msg string, ok bool) { + t.Helper() + if ok { + t.Fatal(msg) + } +} - got, ok := getProfileState(key) +func TestAppStateOperationStatusBypassesFullRender(t *testing.T) { + s, key, _, _ := mustSingleProfile(t) + handle, ok := s.beginOperation(0, key, 0) if !ok { - t.Fatal("profile state missing") + t.Fatal("could not start operation") + } + select { + case <-s.Changes(): + default: + t.Fatal("operation start did not signal a full change") + } + + handle.status("Working...") + select { + case <-s.StatusChanges(): + case <-time.After(time.Second): + t.Fatal("operation status did not signal a status change") } - if got != want { - t.Fatalf("probe update = %+v, want %+v", got, want) + select { + case <-s.Changes(): + t.Fatal("operation status signaled a full change") + default: } - setProfileCronEntry(key, 73) - want.entryID = 73 - got, _ = getProfileState(key) - if got != want { - t.Fatalf("cron update = %+v, want %+v", got, want) + handle.status("Working...") + select { + case <-s.StatusChanges(): + t.Fatal("unchanged operation status signaled another change") + default: } + handle.done() } -func TestProfileStateSurvivesReorder(t *testing.T) { - resetProfileStateForTest(t) +func TestAppStateFallsBackAfterProfileRemoval(t *testing.T) { + s := newAppState() alpha := stateKey("alpha") beta := stateKey("beta") - resetProfileState([]stateKey{alpha, beta}) - setProfileProbeState(alpha, profileState{errMsg: "alpha error"}) - setProfileProbeState(beta, profileState{repoErr: "beta error", needsInit: true}) - setProfileCronEntry(alpha, 11) - setProfileCronEntry(beta, 22) - setActiveProfileKey(beta) + s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha"}, {Name: "beta"}}}) + s.setActiveProfile(beta) - if active := resetProfileState([]stateKey{beta, alpha}); active != beta { - t.Fatalf("active profile after reorder = %q, want %q", active, beta) + s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha"}}}) + snapshot := s.snapshot() + if snapshot.activeProfile != alpha { + t.Fatalf("active profile after removal = %q, want %q", snapshot.activeProfile, alpha) } - alphaState, _ := getProfileState(alpha) - betaState, _ := getProfileState(beta) - if alphaState.errMsg != "alpha error" || alphaState.entryID != 11 { - t.Fatalf("alpha state moved during reorder: %+v", alphaState) + if _, ok := snapshot.profiles[beta]; ok { + t.Fatal("removed profile state was retained") } - if betaState.repoErr != "beta error" || !betaState.needsInit || betaState.entryID != 22 { - t.Fatalf("beta state moved during reorder: %+v", betaState) + + s.replaceConfig(Config{}) + if active := s.snapshot().activeProfile; active != "" { + t.Fatalf("active profile with no profiles = %q, want empty", active) } } -func TestActiveProfileFallsBackAfterRemoval(t *testing.T) { - resetProfileStateForTest(t) - alpha := stateKey("alpha") - beta := stateKey("beta") - resetProfileState([]stateKey{alpha, beta}) - setActiveProfileKey(beta) +func TestAppStateRejectsStaleProbeAndOperationResults(t *testing.T) { + s, key, version, generation := mustSingleProfile(t) + firstProbe, ok := s.beginProbe(key, version, generation) + if !ok { + t.Fatal("could not start initial probe") + } + if !s.finishProbe(key, version, generation, firstProbe, profileState{repoErr: "new"}, time.Time{}) { + t.Fatal("initial probe was rejected") + } - if active := resetProfileState([]stateKey{alpha}); active != alpha { - t.Fatalf("active profile after removal = %q, want %q", active, alpha) + probeID, ok := s.beginProbe(key, version, generation) + if !ok { + t.Fatal("could not start stale probe") } - if _, ok := getProfileState(beta); ok { - t.Fatal("removed profile state was retained") + s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha", Backend: "rustic"}}}) + if s.finishProbe(key, version, generation, probeID, profileState{repoErr: "stale"}, time.Time{}) { + t.Fatal("stale probe was accepted") } - if active := resetProfileState(nil); active != "" { - t.Fatalf("active profile with no profiles = %q, want empty", active) + + s = newAppState() + s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha"}}}) + handle, ok := s.beginOperation(0, key, 0) + if !ok { + t.Fatal("could not start operation") + } + s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha", Backend: "rustic"}}}) + handle.fail("stale operation failure") + handle.lastBackup(time.Now()) + stale := s.snapshot().profiles[key] + if stale.failure != "" || !stale.lastBackup.IsZero() { + t.Fatalf("stale operation result was accepted: %+v", stale) + } + handle.done() + + newHandle, ok := s.beginOperation(0, key, 0) + if !ok { + t.Fatal("could not start replacement operation") } + handle.status("old status") + newHandle.status("new status") + got := s.snapshot().profiles[key] + if !got.operationActive || got.operationStatus != "new status" { + t.Fatalf("operation status = %q, want new status", got.operationStatus) + } + newHandle.done() } -func resetBusyState(t *testing.T) { - t.Helper() +func TestAppStateCoordinatesConcurrentProfileWork(t *testing.T) { + s, key, version, generation := mustSingleProfile(t) + probeID, ok := s.beginProbe(key, version, generation) + if !ok { + t.Fatal("could not start probe") + } + _, ok = s.beginOperation(version, key, generation) + mustBlock(t, "operation started during probe", ok) + _, ok = s.beginMount(version, key, generation) + mustBlock(t, "mount started during probe", ok) + if !s.finishProbe(key, version, generation, probeID, profileState{}, time.Time{}) { + t.Fatal("could not finish probe") + } - state.mu.Lock() - oldBusyProfiles := state.busyProfiles - oldOnBusyChanged := state.onBusyChanged - state.busyProfiles = map[stateKey]*exec.Cmd{busyTransitionGuard: nil} - state.onBusyChanged = nil - state.mu.Unlock() - - t.Cleanup(func() { - state.mu.Lock() - state.busyProfiles = oldBusyProfiles - state.onBusyChanged = oldOnBusyChanged - state.mu.Unlock() - }) -} + mount, ok := s.beginMount(version, key, generation) + if !ok { + t.Fatal("could not start mount") + } + t.Cleanup(func() { mount.done() }) + op, ok := s.beginOperation(version, key, generation) + if !ok { + t.Fatal("could not start operation during mount") + } + t.Cleanup(op.done) + if got := s.snapshot().profiles[key]; !got.operationActive || !got.mounted { + t.Fatalf("operation+mount snapshot = %+v", got) + } + op.done() + probeID, ok = s.beginProbe(key, version, generation) + if !ok { + t.Fatal("could not start probe during mount") + } + if !s.finishProbe(key, version, generation, probeID, profileState{}, time.Time{}) { + t.Fatal("could not finish probe during mount") + } + mount.done() -func TestCancelProfileInterruptsCommand(t *testing.T) { - if os.Getenv("RESTRAY_STATE_TEST_HELPER") == "1" { - time.Sleep(30 * time.Second) - return - } - - resetBusyState(t) - key := stateKey("cancel") - cmd := exec.Command(os.Args[0], "-test.run=^TestCancelProfileInterruptsCommand$") - cmd.Env = append(os.Environ(), "RESTRAY_STATE_TEST_HELPER=1") - if err := cmd.Start(); err != nil { - t.Fatalf("start helper process: %v", err) - } - wait := make(chan error, 1) - go func() { wait <- cmd.Wait() }() - waited := false - t.Cleanup(func() { - if !waited { - _ = cmd.Process.Kill() - <-wait - } - }) + handle, ok := s.beginOperation(version, key, generation) + if !ok { + t.Fatal("could not start operation") + } + t.Cleanup(handle.done) + concurrentMount, ok := s.beginMount(version, key, generation) + if !ok { + t.Fatal("could not start mount during operation") + } + t.Cleanup(func() { concurrentMount.done() }) + _, ok = s.beginProbe(key, version, generation) + mustBlock(t, "probe started during operation", ok) + concurrentMount.done() + handle.done() +} - setProfileBusyCmd(key, cmd) - cancelProfile(key) +func TestAppStateCancelsObsoleteMountOnReload(t *testing.T) { + s, key, version, generation := mustSingleProfile(t) + mount, ok := s.beginMount(version, key, generation) + if !ok { + t.Fatal("could not start mount") + } + newVersion := s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha", Backend: "rustic"}}}) select { - case err := <-wait: - waited = true - if err == nil { - t.Fatal("helper process exited successfully; want interruption") + case <-mount.context().Done(): + case <-time.After(time.Second): + t.Fatal("obsolete mount was not canceled") + } + snapshot := s.snapshot().profiles[key] + if snapshot.mounted || !snapshot.mountStopping { + t.Fatalf("obsolete mount snapshot = %+v", snapshot) + } + newGeneration := s.snapshot().profiles[key].generation + _, ok = s.beginMount(newVersion, key, newGeneration) + mustBlock(t, "replacement mount started before obsolete mount finished", ok) + if !mount.done() { + t.Fatal("obsolete mount did not finish as stopping") + } +} + +func TestAppStateKeepsDownloadsCurrentAcrossReloads(t *testing.T) { + s, key, version, generation := mustSingleProfile(t) + id, ok := s.beginDownload(version, key, generation, "restic") + if !ok { + t.Fatal("could not start download") + } + s.updateDownload(id, "Downloading restic...") + + cfg := Config{Profiles: []Profile{{Name: "alpha"}}} + version = s.replaceConfig(cfg) + snapshot := s.snapshot() + if !snapshot.downloading || snapshot.globalStatus != "Downloading restic..." { + t.Fatalf("download state after equivalent reload = %+v", snapshot) + } + if !s.finishDownload(id, nil) { + t.Fatal("current download was rejected after equivalent reload") + } + + generation = s.snapshot().profiles[key].generation + id2, ok := s.beginDownload(version, key, generation, "restic") + if !ok { + t.Fatal("could not start replacement download") + } + version = s.replaceConfig(Config{Profiles: []Profile{{Name: "alpha", Backend: "rustic"}}}) + if snapshot := s.snapshot(); !snapshot.downloading { + t.Fatal("download lease was released during profile reload") + } + if handle, ok := s.beginOperation(0, key, 0); ok { + handle.done() + t.Fatal("operation started while old download was still active") + } + if !s.mutateIfCurrent(version, key, func() { + s.backendMissing = "Rustic" + s.backendUpdate = "" + if !s.downloading { + s.globalStatus = "Rustic not found" + s.globalStatusErr = true } - case <-time.After(5 * time.Second): - t.Fatal("busy command did not stop after cancellation") + }) { + t.Fatal("could not set replacement backend state") + } + if !s.finishDownload(id2, nil) { + t.Fatal("download completion did not release its lease") } + if snapshot := s.snapshot(); snapshot.downloading || snapshot.globalStatus != "" || snapshot.backendMissing != "Rustic" { + t.Fatalf("download completion left state = %+v", snapshot) + } +} - releaseProfile(key) +func TestAppStateSerializesDownloadsWithWork(t *testing.T) { + s, key, version, generation := mustSingleProfile(t) + id, ok := s.beginDownload(version, key, generation, "restic") + if !ok { + t.Fatal("could not start download") + } + if handle, ok := s.beginOperation(version, key, generation); ok { + handle.done() + t.Fatal("operation started during download") + } + s.finishDownload(id, nil) + + handle, ok := s.beginOperation(version, key, generation) + if !ok { + t.Fatal("could not start operation") + } + t.Cleanup(handle.done) + _, ok = s.beginDownload(version, key, generation, "restic") + mustBlock(t, "download started during operation", ok) + handle.done() + + mount, ok := s.beginMount(version, key, generation) + if !ok { + t.Fatal("could not start mount") + } + t.Cleanup(func() { mount.done() }) + _, ok = s.beginDownload(version, key, generation, "restic") + mustBlock(t, "download started during mount", ok) + mount.done() } diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go index 861639a..83b930e 100644 --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -13,58 +13,12 @@ import ( "github.com/robfig/cron/v3" ) -const ( - maxProfiles = 16 - maxMenuWidth = 64 -) - -type prefixedMenuItem struct { - *systray.MenuItem - prefix string -} - -func (p prefixedMenuItem) SetTitle(title string) { - title = p.prefix + title - if len(title) > maxMenuWidth { - title = title[:maxMenuWidth-1] + "…" - } - p.MenuItem.SetTitle(title) -} - -func setProfileFailed(key stateKey, status string) { - state.mu.Lock() - if status == "" { - delete(state.failStatus, key) - } else { - state.failStatus[key] = status - } - state.mu.Unlock() -} - -func getProfileFailStatus(key stateKey) string { - state.mu.Lock() - defer state.mu.Unlock() - return state.failStatus[key] -} +const maxProfiles = 16 func profileStateHasError(ps profileState) bool { return ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" } -func anyError() bool { - state.mu.Lock() - defer state.mu.Unlock() - if len(state.failStatus) > 0 { - return true - } - for _, ps := range state.profileStates { - if profileStateHasError(ps) { - return true - } - } - return false -} - func profilePrefix(cfg Config, prof Profile) string { if len(cfg.Profiles) > 1 { return prof.displayName() + " - " @@ -72,106 +26,19 @@ func profilePrefix(cfg Config, prof Profile) string { return "" } -func unreachableRecovered(cfg Config) bool { - for _, prof := range cfg.Profiles { - ps, ok := getProfileState(prof.profileKey()) - if !ok || ps.errMsg != "" || !ps.unreachable { - continue - } - if repoStatus(prof).errMsg == "" { - return true - } +func truncateMenuTitle(title string) string { + if r := []rune(title); len(r) > 64 { + return string(r[:63]) + "…" } - return false + return title } -func startTrayBackendMonitor(selectedProfile func() Profile, applyConfig func(), item *systray.MenuItem, status prefixedMenuItem) { - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("backend update checker panicked: %v", r) - } - }() - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - for range ticker.C { - prof := selectedProfile() - backendPath, managed := findBackend(prof) - if managed && !isAnyBusy() { - cfg := loadConfig() - checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), item, status, applyConfig) - } - } - }() -} - -func startTrayStatusMonitor(applyConfig func(), updateStatus, applyProfileUI func(Config)) { - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("status monitor panicked: %v", r) - } - }() - prev := onBatteryPower() - lastWall := time.Now().Round(0) - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for range ticker.C { - now := time.Now().Round(0) - drift := now.Sub(lastWall) - time.Minute - lastWall = now - - if drift.Abs() > 30*time.Second && !isAnyBusy() { - log.Print("clock jump detected, rebuilding scheduler") - applyConfig() - continue - } - - cur := onBatteryPower() - if cur != prev { - prev = cur - if !isAnyBusy() { - cfg := loadConfig() - updateStatus(cfg) - applyProfileUI(cfg) - continue - } - } - - cfg := loadConfig() - if !isAnyBusy() && unreachableRecovered(cfg) { - applyConfig() - continue - } - updateStatus(cfg) - } - }() -} - -func startAppUpdateChecker(item *systray.MenuItem) { - go func() { - check := func() { - latest, err := latestVersion() - if err != nil { - log.Printf("update check failed: %v", err) - return - } - if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { - latestKnownVersion.Store(&latest) - item.SetTitle("Update to v" + latest) - } - } - check() - ticker := time.NewTicker(6 * time.Hour) - defer ticker.Stop() - for range ticker.C { - check() - } - }() +func isCurrent(snapshot appSnapshot, version uint64, key stateKey) bool { + return snapshot.configVersion == version && snapshot.activeProfile == key && snapshot.cfg.loadErr == nil } type trayController struct { - mGlobalStatus prefixedMenuItem + mGlobalStatus *systray.MenuItem mStatusItems [maxProfiles]*systray.MenuItem mProfile *systray.MenuItem profileItems [maxProfiles]*systray.MenuItem @@ -200,19 +67,47 @@ type trayController struct { mAbout *systray.MenuItem mQuit *systray.MenuItem - applyMu sync.Mutex - pendingFullApply bool - schedulerMu sync.RWMutex - scheduler *cron.Cron + reloadSignal chan struct{} + + schedulerMu sync.RWMutex + scheduler *cron.Cron + + renderedIcon string + renderedIconMode string + updatesStarted bool + + renderedMenuItems map[*systray.MenuItem]menuItemState +} + +type menuItemState struct { + title string + visible bool + enabled bool + checked bool +} + +func newHiddenItem(title, tooltip string) *systray.MenuItem { + m := systray.AddMenuItem(title, tooltip) + m.Hide() + return m +} + +func newHiddenSubItem(parent *systray.MenuItem, title, tooltip string) *systray.MenuItem { + m := parent.AddSubMenuItem(title, tooltip) + m.Hide() + return m } func newTrayController() *trayController { - c := &trayController{} - applyIconMode(loadConfig().GUI.Icon) + c := &trayController{ + reloadSignal: make(chan struct{}, 1), + renderedIcon: "idle", + renderedMenuItems: make(map[*systray.MenuItem]menuItemState), + } setIconAnimated("idle") systray.SetTooltip("Restray") - c.mGlobalStatus = prefixedMenuItem{systray.AddMenuItem("", ""), ""} + c.mGlobalStatus = systray.AddMenuItem("", "") c.mGlobalStatus.Disable() c.mGlobalStatus.Hide() for i := range c.mStatusItems { @@ -229,16 +124,11 @@ func newTrayController() *trayController { c.profileItems[i].Hide() } - c.mDownload = systray.AddMenuItem("Install Backend", "Install selected backup backend") - c.mDownload.Hide() - c.mUpdate = systray.AddMenuItem("", "Update backend binary") - c.mUpdate.Hide() - c.mSchedule = systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") - c.mSchedule.Hide() - c.mCancel = systray.AddMenuItem("Cancel Operation", "Cancel running operation") - c.mCancel.Hide() - c.mInit = systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") - c.mInit.Hide() + c.mDownload = newHiddenItem("Install Backend", "Install selected backup backend") + c.mUpdate = newHiddenItem("", "Update backend binary") + c.mCancel = newHiddenItem("Cancel Operation", "Cancel running operation") + c.mSchedule = newHiddenItem("Run Schedule Now", "Run the full schedule immediately") + c.mInit = newHiddenItem("Initialize Repository", "Initialize a new backup repository") c.mRepo = systray.AddMenuItem("Operations", "Repository operations") c.mRepo.Disable() c.mBackup = c.mRepo.AddSubMenuItem("Backup", "Run a backup") @@ -250,20 +140,21 @@ func newTrayController() *trayController { c.mUnlock = c.mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") c.mUnlock.Disable() c.mMount = c.mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") + c.mMount.Disable() + c.mMount.Hide() c.mConsole = c.mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") + c.mConsole.Disable() c.mPreHook = c.mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") c.mPreHook.Disable() c.mPostHook = c.mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") c.mPostHook.Disable() systray.AddSeparator() configure := systray.AddMenuItem("Configure", "Restray settings") - c.mFDA = configure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") - c.mFDA.Hide() + c.mFDA = newHiddenSubItem(configure, "Grant Full Disk Access", "Open System Settings to grant Full Disk Access") c.mWebEditor = configure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") c.mSettings = configure.AddSubMenuItem("Edit Config File", "Open config file in editor") c.mEnv = configure.AddSubMenuItem("Edit Env File", "Open env file in editor") - c.mFixPerms = configure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") - c.mFixPerms.Hide() + c.mFixPerms = newHiddenSubItem(configure, "Fix Permissions", "Config directory, config file, or env file permissions too open") c.mLog = configure.AddSubMenuItem("View Log", "Open log file in editor") c.mFolder = configure.AddSubMenuItem("Open Folder", "Open folder in file manager") c.mAbout = configure.AddSubMenuItem("Restray v"+version, "Open repository in browser") @@ -272,319 +163,198 @@ func newTrayController() *trayController { return c } -func onReady() { - newTrayController().start(!fileExists(configPath())) -} - -func (c *trayController) start(firstLaunch bool) { - state.mu.Lock() - state.onBusyChanged = c.onBusyChanged - state.mu.Unlock() - go c.applyConfig() - if firstLaunch && needsFullDiskAccess() { - go promptFullDiskAccess() - } - watchConfig(c.applyConfig) - - startTrayBackendMonitor(c.selectedProfile, c.applyConfig, c.mUpdate, c.mGlobalStatus) - startTrayStatusMonitor(c.applyConfig, c.updateAllStatusItems, c.applyProfileUI) - go c.handleClicks() - - if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { - startAppUpdateChecker(c.mAbout) +func (c *trayController) setMenuTitle(item *systray.MenuItem, title string) { + state, ok := c.renderedMenuItems[item] + if !ok || state.title != title { + item.SetTitle(title) + state.title = title + c.renderedMenuItems[item] = state } - c.startProfileSelectionHandlers() - c.startProfileStatusHandlers() -} - -func (c *trayController) onBusyChanged(busy bool) { - if busy { - c.mCancel.Show() - c.mSchedule.Hide() - c.mInit.Hide() - c.mBackup.Disable() - c.mPrune.Disable() - c.mCheck.Disable() - c.mUnlock.Disable() - c.mPreHook.Disable() - c.mPostHook.Disable() - } - c.updateProfileStatusItems(loadConfig()) } -func (c *trayController) stopScheduler() { - c.schedulerMu.RLock() - defer c.schedulerMu.RUnlock() - if c.scheduler != nil { - c.scheduler.Stop() +func (c *trayController) setMenuVisible(item *systray.MenuItem, visible bool) { + state, ok := c.renderedMenuItems[item] + if !ok || state.visible != visible { + if visible { + item.Show() + } else { + item.Hide() + } + state.visible = visible + c.renderedMenuItems[item] = state } } -func (c *trayController) formatNext(entryID cron.EntryID) string { - c.schedulerMu.RLock() - defer c.schedulerMu.RUnlock() - if c.scheduler == nil || entryID == 0 { - return "" - } - entry := c.scheduler.Entry(entryID) - if entry.ID == 0 { - return "" +func (c *trayController) setMenuEnabled(item *systray.MenuItem, enabled bool) { + state, ok := c.renderedMenuItems[item] + if !ok || state.enabled != enabled { + if enabled { + item.Enable() + } else { + item.Disable() + } + state.enabled = enabled + c.renderedMenuItems[item] = state } - return humanize.Time(entry.Schedule.Next(time.Now())) } -func profileKeys(cfg Config) []stateKey { - keys := make([]stateKey, 0, len(cfg.Profiles)) - for _, prof := range cfg.Profiles { - keys = append(keys, prof.profileKey()) +func (c *trayController) setMenuChecked(item *systray.MenuItem, checked bool) { + state, ok := c.renderedMenuItems[item] + if !ok || state.checked != checked { + if checked { + item.Check() + } else { + item.Uncheck() + } + state.checked = checked + c.renderedMenuItems[item] = state } - return keys } -func (c *trayController) activeProfile(cfg Config) (int, Profile, bool) { - if idx, prof, err := resolveProfileIndex(cfg, activeProfileKey()); err == nil { - return idx, prof, true - } - if len(cfg.Profiles) == 0 || cfg.loadErr != nil { - return 0, Profile{}, false - } - return 0, cfg.Profiles[0], true +func onReady() { + newTrayController().start(!fileExists(configPath())) } -func (c *trayController) selectedProfile() Profile { - _, prof, ok := c.activeProfile(loadConfig()) - if ok { - return prof +func (c *trayController) start(firstLaunch bool) { + go c.run() + watchConfig(c.requestConfigReload) + c.startStatusMonitor() + c.startBackendMonitor() + go c.handleClicks() + c.startMenuHandlers() + c.requestConfigReload() + if firstLaunch && needsFullDiskAccess() { + go promptFullDiskAccess() } - return Profile{} } -func (c *trayController) setProfileTitle(cfg Config, idx int) { - if idx < len(cfg.Profiles) { - c.mProfile.SetTitle(cfg.Profiles[idx].displayName()) - } - if len(cfg.Profiles) > 1 { - c.mProfile.Enable() - } else { - c.mProfile.Disable() +func (c *trayController) requestConfigReload() { + select { + case c.reloadSignal <- struct{}{}: + default: } } -func (c *trayController) markActiveProfile(active, count int) { - for i := 0; i < count && i < maxProfiles; i++ { - if i == active { - c.profileItems[i].Check() - c.profileItems[i].Disable() - } else { - c.profileItems[i].Uncheck() - c.profileItems[i].Enable() +func (c *trayController) run() { + renderTicker := time.NewTicker(time.Minute) + defer renderTicker.Stop() + c.render() + for { + select { + case <-state.Changes(): + snapshot := state.snapshot() + if snapshot.cfg.loadErr == nil && !snapshot.downloading { + c.probeWhere(snapshot, func(profile profileSnapshot) bool { + return profile.state.probing && !profile.operationActive && !profile.mountStopping + }) + } + c.render() + case <-state.StatusChanges(): + c.renderOperationStatuses(state.snapshot()) + case <-renderTicker.C: + c.render() + case <-c.reloadSignal: + c.applyConfig(loadConfig()) } } } -func createOrEdit(m *systray.MenuItem, label, path string) { - if _, err := os.Stat(path); err != nil { - m.SetTitle("Create " + label) - } else { - m.SetTitle("Edit " + label) - } - m.Enable() -} - -func (c *trayController) hideActions() { - c.mSchedule.Hide() - c.mInit.Hide() - c.mDownload.Hide() - c.mUpdate.Hide() - c.mRepo.Disable() -} - -func (c *trayController) doDownload() { - setIconAnimated("download") - if err := downloadBackend(c.selectedProfile(), c.mGlobalStatus); err != nil { - setIconAnimated("fail") - c.mGlobalStatus.SetTitle("Download failed: " + err.Error()) - c.mDownload.Show() +func (c *trayController) applyConfig(cfg Config) { + version := state.replaceConfig(cfg) + c.stopScheduler() + if cfg.loadErr != nil || len(cfg.Profiles) == 0 { return } - c.applyConfig() -} - -func (c *trayController) profileStatusText(cfg Config, idx int) string { - if idx < 0 || idx >= len(cfg.Profiles) { - return "" + c.startScheduler(cfg, version) + snapshot := state.snapshot() + if snapshot.cfg.loadErr == nil && !snapshot.downloading { + c.probeWhere(snapshot, func(profile profileSnapshot) bool { + return profile.state.probing && !profile.operationActive && !profile.mountStopping + }) } - prof := cfg.Profiles[idx] - ps, ok := getProfileState(prof.profileKey()) - if !ok { - return "" + go c.prepareBackend(version) + + if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && cfg.GUI.UpdatesEnabled() && !c.updatesStarted { + c.updatesStarted = true + c.startAppUpdateChecker() } - prefix := profilePrefix(cfg, prof) +} - switch { - case ps.errMsg != "": - return prefix + ps.errMsg - case ps.scheduleErr != "": - return prefix + ps.scheduleErr - case ps.repoErr != "": - return prefix + ps.repoErr - default: - paused := schedulePausedForBattery(prof) +func (c *trayController) startScheduler(cfg Config, version uint64) { + sched := cron.New() + snapshot := state.snapshot() + for idx, prof := range cfg.Profiles { + if idx >= maxProfiles { + break + } + key := prof.profileKey() + generation := snapshot.profiles[key].generation if prof.Schedule.Cron == "" { - if cfg.GUI.ScheduleDisplay == "last" { - return prefix + "Unscheduled, last " + formatLastBackup(getLastBackup(prof.profileKey())) - } - return prefix + "Unscheduled" + continue } - if paused { - return prefix + "Paused (battery)" + if errMsg := prof.scheduleDefinitionError(); errMsg != "" { + state.updateProfile(key, version, generation, func(r *profileRecord) { + r.state.scheduleErr = errMsg + }) + continue } - next := c.formatNext(ps.entryID) - switch cfg.GUI.ScheduleDisplay { - case "none", "hidden": - if next != "" { - return prefix + next - } - return prefix + "Scheduled" - case "cron": - if next != "" { - return prefix + next + " - " + prof.Schedule.Cron - } - return prefix + prof.Schedule.Cron - case "last": - last := formatLastBackup(getLastBackup(prof.profileKey())) - if next != "" { - return prefix + next + ", last " + last - } - return prefix + "last " + last - default: - schedule := describeCron(prof.Schedule.Cron) - if next != "" { - return prefix + next + " - " + schedule - } - return prefix + schedule + entryID, err := sched.AddFunc(prof.Schedule.Cron, func() { + c.runScheduled(version, key) + }) + if err != nil { + log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) + state.updateProfile(key, version, generation, func(r *profileRecord) { + r.state.scheduleErr = "Invalid cron: " + prof.Schedule.Cron + }) + continue } + state.updateProfile(key, version, generation, func(r *profileRecord) { + r.state.entryID = entryID + }) } + sched.Start() + c.schedulerMu.Lock() + c.scheduler = sched + c.schedulerMu.Unlock() } -func (c *trayController) updateAllStatusItems(cfg Config) { - c.mGlobalStatus.Hide() - c.updateProfileStatusItems(cfg) - for i := len(cfg.Profiles); i < maxProfiles; i++ { - c.mStatusItems[i].Hide() - } -} - -func (c *trayController) updateProfileStatusItems(cfg Config) { - for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { - c.updateProfileStatusItem(cfg, i) +func (c *trayController) stopScheduler() { + c.schedulerMu.Lock() + sched := c.scheduler + c.scheduler = nil + c.schedulerMu.Unlock() + if sched != nil { + sched.Stop() } } -func (c *trayController) updateProfileStatusItem(cfg Config, idx int) { - prof := cfg.Profiles[idx] +func (c *trayController) startProbe(cfg Config, version, generation uint64, prof Profile, cachedLastBackup time.Time) { key := prof.profileKey() - busy := isProfileBusy(key) - failMsg := getProfileFailStatus(key) - if !busy { - if failMsg != "" { - prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}.SetTitle(failMsg) - } else { - c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) - } - } - - ps, ok := getProfileState(key) - if !busy && (failMsg != "" || (ok && profileStateHasError(ps))) { - c.mStatusItems[idx].Enable() - } else { - c.mStatusItems[idx].Disable() - } - c.mStatusItems[idx].Show() -} - -func (c *trayController) applyProfileUI(cfg Config) { - _, prof, ok := c.activeProfile(cfg) + probeID, ok := state.beginProbe(key, version, generation) if !ok { return } - ps, ok := getProfileState(prof.profileKey()) - if !ok { - return - } - - createOrEdit(c.mEnv, "Env File", prof.EnvFile) - if envFilesInsecure(cfg.Profiles) { - c.mFixPerms.Show() - } else { - c.mFixPerms.Hide() - } - - c.mSchedule.Hide() - c.mInit.Hide() - c.mRepo.Disable() - c.mPreHook.Disable() - c.mPostHook.Disable() - - c.mMount.Show() - if isProfileMounted(prof.profileKey()) { - c.mMount.SetTitle("Unmount") - c.mMount.Enable() - } else if mountSupported(prof) { - c.mMount.SetTitle("Mount") - c.mMount.Enable() - } else { - c.mMount.SetTitle("Mount") - c.mMount.Disable() - } - - if isProfileBusy(prof.profileKey()) { - c.mCancel.Show() - c.mRepo.Enable() - c.mBackup.Disable() - c.mPrune.Disable() - c.mCheck.Disable() - c.mUnlock.Disable() - return - } + go func() { + probe, lastBackup := c.probeProfileState(cfg, prof, cachedLastBackup) + probe.mountable = mountSupported(prof) + state.finishProbe(key, version, generation, probeID, probe, lastBackup) + }() +} - c.mCancel.Hide() - switch { - case ps.errMsg != "": - case ps.repoErr != "": - if ps.needsInit { - c.mInit.Show() - } - default: - c.mRepo.Enable() - if len(prof.Backup.Paths) > 0 { - c.mBackup.Enable() - } else { - c.mBackup.Disable() - } - c.mPrune.Enable() - c.mCheck.Enable() - if prof.backend().supportsUnlock() { - c.mUnlock.Enable() - } else { - c.mUnlock.Disable() - } - c.mSchedule.Show() - if prof.scheduleDefinitionError() == "" { - c.mSchedule.Enable() - } else { - c.mSchedule.Disable() - } - if prof.PreHook != "" { - c.mPreHook.Enable() +func (c *trayController) probeWhere(snapshot appSnapshot, want func(profileSnapshot) bool) { + for key, profile := range snapshot.profiles { + if !want(profile) { + continue } - if prof.PostHook != "" { - c.mPostHook.Enable() + idx, prof, err := resolveProfileIndex(snapshot.cfg, key) + if err != nil || idx >= maxProfiles { + continue } + c.startProbe(snapshot.cfg, snapshot.configVersion, profile.generation, prof, profile.lastBackup) } } -func (c *trayController) probeProfileState(cfg Config, prof Profile) profileState { +func (c *trayController) probeProfileState(cfg Config, prof Profile, cachedLastBackup time.Time) (profileState, time.Time) { var ps profileState if prof.Schedule.Cron != "" { ps.scheduleErr = prof.scheduleDefinitionError() @@ -596,448 +366,746 @@ func (c *trayController) probeProfileState(cfg Config, prof Profile) profileStat } if path, _ := findBackend(prof); path == "" { ps.errMsg = prof.backendDisplayName() + " not found" - return ps + return ps, time.Time{} } ps.errMsg = prof.repositoryError() if ps.errMsg == "" { - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() + wantsLast := cfg.GUI.ScheduleDisplay == "last" && cachedLastBackup.IsZero() var rs repoResult + var last time.Time if wantsLast { - var last time.Time rs, last = repoStatusAndLastBackup(prof) - if !last.IsZero() { - setLastBackup(prof.profileKey(), last) - } } else { rs = repoStatus(prof) } ps.repoErr = rs.errMsg ps.unreachable = rs.unreachable ps.needsInit = rs.needsInit + return ps, last } - return ps + return ps, time.Time{} } -func (c *trayController) refreshProfile(key stateKey) { - c.applyMu.Lock() - cfg := loadConfig() - idx, prof, err := resolveProfileIndex(cfg, key) - if err != nil || idx >= maxProfiles { - c.applyMu.Unlock() - c.applyConfig() +func (c *trayController) prepareBackend(version uint64) { + snapshot := state.snapshot() + idx, prof, ok := c.activeProfile(snapshot) + if !ok || idx >= maxProfiles { + return + } + if !isCurrent(snapshot, version, prof.profileKey()) { return } - defer c.applyMu.Unlock() + path, managed := findBackend(prof) + c.handleBackendReady(version, prof.profileKey(), prof, path, managed) +} - c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") - setProfileProbeState(key, c.probeProfileState(cfg, prof)) - c.updateProfileStatusItem(cfg, idx) - if !isAnyBusy() { - if anyError() { - setIconAnimated("fail") - } else { - setIconAnimated("idle") +func (c *trayController) handleBackendReady(version uint64, key stateKey, prof Profile, path string, managed bool) { + snapshot := state.snapshot() + if !isCurrent(snapshot, version, key) { + return + } + if path == "" { + name := prof.backendDisplayName() + state.mutateIfCurrent(version, key, func() { + state.backendMissing = name + state.backendUpdate = "" + if name != "" && !state.downloading { + state.globalStatus = name + " not found" + state.globalStatusErr = true + } + }) + if selfManagesBackend && snapshot.cfg.GUI.BackendManagementEnabled() { + c.startDownload(version, key, snapshot.profiles[key].generation, prof) } + return + } + state.mutateIfCurrent(version, key, func() { + state.backendMissing = "" + state.backendUpdate = "" + if !state.downloading { + state.globalStatus = "" + state.globalStatusErr = false + } + }) + if managed && !snapshot.busy { + go func() { + title := checkBackendUpdate(prof, path) + if title != "" { + c.handleBackendUpdate(version, key, prof, path, title) + } + }() } - c.applyProfileUI(cfg) } -func (c *trayController) recheckProfile(idx int) { - cfg := loadConfig() - if idx >= len(cfg.Profiles) || idx >= maxProfiles { +func (c *trayController) handleBackendUpdate(version uint64, key stateKey, prof Profile, path string, title string) { + snapshot := state.snapshot() + if !isCurrent(snapshot, version, key) { return } - prof := cfg.Profiles[idx] - key := prof.profileKey() - if isProfileBusy(key) { + if selfManagesBackend && path != "" && snapshot.cfg.GUI.BackendManagementEnabled() { + c.startDownload(version, key, snapshot.profiles[key].generation, prof) return } - - c.mStatusItems[idx].Disable() - setProfileFailed(key, "") - c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") - c.refreshProfile(key) + state.mutateIfCurrent(version, key, func() { + state.backendUpdate = title + }) } -func (c *trayController) finishProfile(key stateKey) { - c.applyMu.Lock() - pending := c.pendingFullApply - c.pendingFullApply = false - c.applyMu.Unlock() - if pending { - c.applyConfig() +func (c *trayController) startDownload(version uint64, key stateKey, generation uint64, prof Profile) { + id, ok := state.beginDownload(version, key, generation, prof.backendName()) + if !ok { return } - c.refreshProfile(key) + go func() { + err := downloadBackend(prof, func(status string) { state.updateDownload(id, status) }) + state.finishDownload(id, err) + if err == nil { + c.requestConfigReload() + } + }() +} + +func (c *trayController) startTicker(d time.Duration, fn func()) { + go func() { + ticker := time.NewTicker(d) + defer ticker.Stop() + for range ticker.C { + fn() + } + }() } -func (c *trayController) applyConfig() { - c.applyMu.Lock() - defer c.applyMu.Unlock() +func (c *trayController) startBackendMonitor() { + c.startTicker(24*time.Hour, func() { + snapshot := state.snapshot() + if snapshot.busy || snapshot.cfg.loadErr != nil { + return + } + idx, prof, ok := c.activeProfile(snapshot) + if !ok || idx >= maxProfiles { + return + } + path, managed := findBackend(prof) + if !managed || path == "" { + return + } + version := snapshot.configVersion + key := prof.profileKey() + go func() { + title := checkBackendUpdate(prof, path) + if title != "" { + c.handleBackendUpdate(version, key, prof, path, title) + } + }() + }) +} - if isAnyBusy() { - c.pendingFullApply = true - cfg := loadConfig() - if applyIconMode(cfg.GUI.Icon) { - refreshIcon() +func (c *trayController) startStatusMonitor() { + prev := onBatteryPower() + state.setBattery(prev) + lastWall := time.Now().Round(0) + c.startTicker(time.Minute, func() { + now := time.Now().Round(0) + drift := now.Sub(lastWall) - time.Minute + lastWall = now + snapshot := state.snapshot() + if drift.Abs() > 30*time.Second && !snapshot.busy { + log.Print("clock jump detected, rebuilding scheduler") + c.requestConfigReload() + return } - activeKey := resetProfileState(profileKeys(cfg)) - activeIdx, _, err := resolveProfileIndex(cfg, activeKey) + + cur := onBatteryPower() + if cur != prev { + prev = cur + state.setBattery(cur) + } + if !snapshot.busy { + c.probeWhere(state.snapshot(), func(profile profileSnapshot) bool { + return profile.state.unreachable && !profile.operationActive + }) + } + }) +} + +func (c *trayController) startAppUpdateChecker() { + check := func() { + configVersion := state.snapshot().configVersion + latest, err := latestVersion() if err != nil { - activeIdx = 0 + log.Printf("update check failed: %v", err) + return + } + if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { + latestKnownVersion.Store(&latest) + state.setAppUpdateIfCurrent(configVersion, "Update to v"+latest) } - c.markActiveProfile(activeIdx, len(cfg.Profiles)) - c.setProfileTitle(cfg, activeIdx) - c.updateAllStatusItems(cfg) - c.applyProfileUI(cfg) - return } + check() + c.startTicker(6*time.Hour, check) +} - c.pendingFullApply = false - cfg := loadConfig() - applyIconMode(cfg.GUI.Icon) - if cfg.loadErr != nil { - setIconAnimated("fail") - c.mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr)) - c.mGlobalStatus.Show() - c.hideActions() - c.mProfile.Disable() - for i := range c.mStatusItems { - c.mStatusItems[i].Hide() - c.profileItems[i].Hide() - } +func (c *trayController) activeProfile(snapshot appSnapshot) (int, Profile, bool) { + if idx, prof, err := resolveProfileIndex(snapshot.cfg, snapshot.activeProfile); err == nil { + return idx, prof, true + } + if len(snapshot.cfg.Profiles) == 0 || snapshot.cfg.loadErr != nil { + return 0, Profile{}, false + } + return 0, snapshot.cfg.Profiles[0], true +} + +func (c *trayController) withActive(fn func(Profile, appSnapshot)) { + snapshot := state.snapshot() + idx, prof, ok := c.activeProfile(snapshot) + if !ok || idx >= maxProfiles { return } - state.mu.Lock() - state.notifications = strings.ToLower(cfg.GUI.Notifications) - state.mu.Unlock() + fn(prof, snapshot) +} - c.schedulerMu.Lock() - oldScheduler := c.scheduler - c.scheduler = nil - c.schedulerMu.Unlock() - if oldScheduler != nil { - oldScheduler.Stop() +func (c *trayController) withConfigFile(fn func()) { + if err := ensureConfigFile(); err != nil { + log.Printf("config: %v", err) + return } + fn() +} - createOrEdit(c.mSettings, "Config File", configPath()) - c.mDownload.Hide() - c.mUpdate.Hide() +func (c *trayController) quit() { + c.stopScheduler() + state.cancelAllMounts() + systray.Quit() +} - keys := profileKeys(cfg) - activeKey := resetProfileState(keys) - for _, key := range keys { - setProfileCronEntry(key, 0) +func (c *trayController) openBackendDocs() { + c.withActive(func(prof Profile, _ appSnapshot) { + if prof.backend() == BackendRustic { + openFile("https://rustic.cli.rs/docs/installation.html") + } else { + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") + } + }) +} + +func (c *trayController) handleBackendDownload(fromUpdate bool) { + if selfManagesBackend { + c.downloadSelected() + } else if !fromUpdate { + c.openBackendDocs() } - activeIdx, prof, err := resolveProfileIndex(cfg, activeKey) - if err != nil { - activeIdx = 0 - prof = Profile{} +} + +func (c *trayController) render() { + snapshot := state.snapshot() + iconMode := normalizeIconMode(snapshot.cfg.GUI.Icon) + iconModeChanged := c.renderedIconMode != iconMode + if iconModeChanged { + applyIconMode(iconMode) + c.renderedIconMode = iconMode + snapshot = state.snapshot() + } + if iconModeChanged || iconDirty.Swap(false) { + alertMu.RLock() + name := currentIcon + alertMu.RUnlock() + setIconAnimated(name) + } + anyErr := false + if !snapshot.downloading && !snapshot.busy { + for _, profile := range snapshot.profiles { + if profile.failure != "" || profileStateHasError(profile.state) { + anyErr = true + break + } + } + } + icon := "idle" + switch { + case snapshot.downloading: + icon = "download" + case snapshot.busy: + icon = "busy" + case snapshot.globalStatusErr || anyErr: + icon = "fail" + } + if icon != c.renderedIcon { + c.renderedIcon = icon + setIconAnimated(icon) } - backendPath, managed := findBackend(prof) - backendName := prof.backendDisplayName() - if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { - c.mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") - c.mGlobalStatus.Show() - go c.doDownload() + if snapshot.cfg.loadErr != nil { + c.setMenuTitle(c.mGlobalStatus, truncateMenuTitle(configLoadErrorMessage(snapshot.cfg.loadErr))) + c.renderNoProfiles(true) return } - if backendPath == "" { - setIconAnimated("fail") - c.mGlobalStatus.SetTitle(backendName + " not found") - c.mGlobalStatus.Show() - c.hideActions() - c.mDownload.SetTitle("Install " + backendName) - c.mDownload.Show() - c.mProfile.Disable() + if len(snapshot.cfg.Profiles) == 0 { + c.renderNoProfiles(false) return } + c.renderConfig(snapshot) +} - if needsFullDiskAccess() { - c.mFDA.Show() +func (c *trayController) renderNoProfiles(showStatus bool) { + c.setMenuVisible(c.mGlobalStatus, showStatus) + c.setMenuEnabled(c.mProfile, false) + c.hideOperationalActions() + c.setMenuVisible(c.mUpdate, false) + c.setMenuVisible(c.mDownload, false) + c.hideProfileItems(0) +} + +func (c *trayController) renderConfig(snapshot appSnapshot) { + cfg := snapshot.cfg + idle := !snapshot.downloading && !snapshot.busy + if snapshot.globalStatus != "" { + c.setMenuTitle(c.mGlobalStatus, truncateMenuTitle(snapshot.globalStatus)) + c.setMenuVisible(c.mGlobalStatus, true) } else { - c.mFDA.Hide() + c.setMenuVisible(c.mGlobalStatus, false) + } + if snapshot.backendMissing != "" && idle { + c.setMenuTitle(c.mDownload, "Install "+snapshot.backendMissing) + c.setMenuVisible(c.mDownload, true) + } else { + c.setMenuVisible(c.mDownload, false) + } + if snapshot.backendUpdate != "" && idle { + c.setMenuTitle(c.mUpdate, snapshot.backendUpdate) + c.setMenuVisible(c.mUpdate, true) + } else { + c.setMenuVisible(c.mUpdate, false) + } + aboutTitle := "Restray v" + version + if snapshot.appUpdate != "" { + aboutTitle = snapshot.appUpdate + } + c.setMenuTitle(c.mAbout, aboutTitle) + _, confProf, confOK := c.activeProfile(snapshot) + if confOK { + prefix := "Edit " + if _, err := os.Stat(configPath()); err != nil { + prefix = "Create " + } + c.setMenuTitle(c.mSettings, prefix+"Config File") + c.setMenuEnabled(c.mSettings, true) + prefix = "Edit " + if _, err := os.Stat(confProf.EnvFile); err != nil { + prefix = "Create " + } + c.setMenuTitle(c.mEnv, prefix+"Env File") + c.setMenuEnabled(c.mEnv, true) + c.setMenuVisible(c.mFixPerms, envFilesInsecure(cfg.Profiles)) + c.setMenuVisible(c.mFDA, needsFullDiskAccess()) } - nextScheduler := cron.New() - nextScheduler.Start() - c.schedulerMu.Lock() - c.scheduler = nextScheduler - c.schedulerMu.Unlock() - - c.markActiveProfile(activeIdx, len(cfg.Profiles)) - c.setProfileTitle(cfg, activeIdx) - setIconAnimated("busy") - - var wg sync.WaitGroup - for i, prof := range cfg.Profiles { - label := prof.displayName() - if i < maxProfiles { - connecting := "Connecting..." - if len(cfg.Profiles) > 1 { - connecting = label + " - " + connecting - } - c.mStatusItems[i].SetTitle(connecting) - c.mStatusItems[i].Disable() - c.mStatusItems[i].Show() - c.profileItems[i].SetTitle(label) - c.profileItems[i].Show() + activeIdx := 0 + for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { + prof := cfg.Profiles[i] + key := prof.profileKey() + if key == snapshot.activeProfile { + activeIdx = i } - wg.Add(1) - go func() { - defer wg.Done() - c.initializeProfile(cfg, nextScheduler, i, prof, activeKey) - }() + c.setMenuTitle(c.profileItems[i], prof.displayName()) + c.setMenuVisible(c.profileItems[i], true) + profile := snapshot.profiles[key] + prefix := profilePrefix(cfg, prof) + var title string + switch { + case profile.operationActive: + title = activeOperationTitle(prefix, profile) + case profile.failure != "": + title = prefix + profile.failure + default: + title = c.profileStatusText(snapshot, prof, profile.state, prefix, profile.lastBackup) + } + c.setMenuTitle(c.mStatusItems[i], truncateMenuTitle(title)) + c.setMenuEnabled(c.mStatusItems[i], !profile.operationActive && (profile.failure != "" || profileStateHasError(profile.state))) + c.setMenuVisible(c.mStatusItems[i], true) } - wg.Wait() + c.setMenuTitle(c.mProfile, cfg.Profiles[activeIdx].displayName()) + c.setMenuEnabled(c.mProfile, len(cfg.Profiles) > 1) + for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { + if i == activeIdx { + c.setMenuChecked(c.profileItems[i], true) + c.setMenuEnabled(c.profileItems[i], false) + } else { + c.setMenuChecked(c.profileItems[i], false) + c.setMenuEnabled(c.profileItems[i], true) + } + } + c.hideProfileItems(len(cfg.Profiles)) - for i := len(cfg.Profiles); i < maxProfiles; i++ { - c.profileItems[i].Hide() - c.profileItems[i].Uncheck() + if snapshot.backendMissing != "" { + if !snapshot.busy { + c.hideOperationalActions() + return + } } - if anyError() { - setIconAnimated("fail") - } else { - setIconAnimated("idle") + _, prof, ok := c.activeProfile(snapshot) + if ok { + c.renderActiveProfile(snapshot, prof) } - c.updateAllStatusItems(cfg) - c.applyProfileUI(cfg) +} - if managed { - go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), c.mUpdate, c.mGlobalStatus, c.applyConfig) +func activeOperationTitle(prefix string, profile profileSnapshot) string { + status := "Working..." + if profile.operationStatus != "" { + status = profile.operationStatus } + return prefix + status } -func (c *trayController) initializeProfile(cfg Config, sched *cron.Cron, idx int, prof Profile, activeKey stateKey) { - key := prof.profileKey() - ps := c.probeProfileState(cfg, prof) - if prof.Schedule.Cron != "" && idx < maxProfiles { - if ps.scheduleErr != "" { - log.Printf("[%s] skipping schedule: %s", prof.displayName(), ps.scheduleErr) +func (c *trayController) renderOperationStatuses(snapshot appSnapshot) { + if snapshot.cfg.loadErr != nil { + return + } + for idx, prof := range snapshot.cfg.Profiles { + if idx >= maxProfiles { + break + } + key := prof.profileKey() + profile := snapshot.profiles[key] + if !profile.operationActive { + continue + } + c.setMenuTitle(c.mStatusItems[idx], truncateMenuTitle(activeOperationTitle(profilePrefix(snapshot.cfg, prof), profile))) + } +} + +func (c *trayController) profileStatusText(snapshot appSnapshot, prof Profile, ps profileState, prefix string, lastBackup time.Time) string { + var suffix string + switch { + case ps.probing: + suffix = "Connecting..." + case ps.errMsg != "": + suffix = ps.errMsg + case ps.scheduleErr != "": + suffix = ps.scheduleErr + case ps.repoErr != "": + suffix = ps.repoErr + default: + if prof.Schedule.Cron == "" { + if snapshot.cfg.GUI.ScheduleDisplay == "last" { + suffix = "Unscheduled, last " + formatLastBackup(lastBackup) + } else { + suffix = "Unscheduled" + } + } else if !prof.Schedule.OnBattery && snapshot.batteryOn { + suffix = "Paused (battery)" } else { - eid, err := sched.AddFunc(prof.Schedule.Cron, func() { - runScheduleCallback(key, func() { c.finishProfile(key) }, func(currentCfg Config, currentIdx int, current Profile) { - if currentIdx >= maxProfiles { - return - } - item := prefixedMenuItem{c.mStatusItems[currentIdx], profilePrefix(currentCfg, current)} - runScheduledAcquired(key, item, current) - }) - }) - if err != nil { - log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) - ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron + var next string + c.schedulerMu.RLock() + if c.scheduler != nil { + if entry := c.scheduler.Entry(ps.entryID); entry.ID != 0 { + next = humanize.Time(entry.Schedule.Next(time.Now())) + } + } + c.schedulerMu.RUnlock() + last := formatLastBackup(lastBackup) + mode := snapshot.cfg.GUI.ScheduleDisplay + var detail, sep string + switch mode { + case "none", "hidden": + detail, sep = "Scheduled", "" + case "cron": + detail, sep = prof.Schedule.Cron, " - " + case "last": + detail, sep = "last "+last, ", " + default: + detail, sep = describeCron(prof.Schedule.Cron), " - " + } + if next == "" { + suffix = detail + } else if mode == "none" || mode == "hidden" { + suffix = next } else { - setProfileCronEntry(key, eid) + suffix = next + sep + detail } } } - setProfileProbeState(key, ps) - if idx < maxProfiles { - c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) + return prefix + suffix +} + +func (c *trayController) hideOperationalActions() { + for _, m := range []*systray.MenuItem{c.mSchedule, c.mCancel, c.mInit, c.mMount} { + c.setMenuVisible(m, false) } - if key == activeKey { - c.applyProfileUI(cfg) + for _, m := range []*systray.MenuItem{c.mInit, c.mRepo, c.mBackup, c.mPrune, c.mCheck, c.mUnlock, c.mMount, c.mConsole, c.mPreHook, c.mPostHook} { + c.setMenuEnabled(m, false) } } -func (c *trayController) selectedOperation() (Profile, prefixedMenuItem, bool) { - cfg := loadConfig() - idx, prof, ok := c.activeProfile(cfg) - if !ok || idx >= maxProfiles { - return Profile{}, prefixedMenuItem{}, false +func (c *trayController) hideProfileItems(start int) { + for i := start; i < maxProfiles; i++ { + c.setMenuVisible(c.mStatusItems[i], false) + c.setMenuVisible(c.profileItems[i], false) + c.setMenuChecked(c.profileItems[i], false) } - return prof, prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}, true } -func (c *trayController) repoOp(prof Profile, ms prefixedMenuItem, status string, args ...string) { - key := prof.profileKey() - go func() { - if !acquireProfile(key) { - return - } - setProfileFailed(key, "") - defer c.finishProfile(key) - defer releaseProfile(key) - ms.SetTitle(status) - if msg, err := runBackend(key, prof, ms, args...); err != nil { - setProfileFailed(key, msg) - notifyError(args[0], msg) +func (c *trayController) renderActiveProfile(snapshot appSnapshot, prof Profile) { + profile := snapshot.profiles[prof.profileKey()] + state := profile.state + + showInit := false + enableBackup, enablePrune, enableCheck, enableUnlock := false, false, false, false + mountTitle, showMount, enableMount := "", false, false + enableConsole := profile.operationActive + enablePreHook, enablePostHook := false, false + showSchedule, enableSchedule := false, false + + if !snapshot.downloading && !profile.mountStopping { + if profile.mounted { + mountTitle, showMount, enableMount = "Unmount", true, true } else { - notifySuccess(args[0]) + mountTitle, showMount, enableMount = "Mount", true, state.mountable && !state.probing } - }() + showSchedule = true + if !profile.operationActive && !state.probing && state.errMsg == "" { + if state.repoErr != "" { + showInit = state.needsInit + } else { + enableConsole = true + enableBackup = len(prof.Backup.Paths) > 0 + enablePrune, enableCheck = true, true + enableUnlock = prof.backend().supportsUnlock() + enableSchedule = prof.scheduleDefinitionError() == "" + enablePreHook = prof.PreHook != "" + enablePostHook = prof.PostHook != "" + } + } + } + + if mountTitle != "" { + c.setMenuTitle(c.mMount, mountTitle) + } + c.setMenuVisible(c.mSchedule, showSchedule) + c.setMenuEnabled(c.mSchedule, enableSchedule) + c.setMenuVisible(c.mCancel, profile.operationActive) + c.setMenuVisible(c.mInit, showInit) + c.setMenuEnabled(c.mInit, showInit) + c.setMenuEnabled(c.mRepo, true) + c.setMenuEnabled(c.mBackup, enableBackup) + c.setMenuEnabled(c.mPrune, enablePrune) + c.setMenuEnabled(c.mCheck, enableCheck) + c.setMenuEnabled(c.mUnlock, enableUnlock) + c.setMenuVisible(c.mMount, showMount) + c.setMenuEnabled(c.mMount, enableMount) + c.setMenuEnabled(c.mConsole, enableConsole) + c.setMenuEnabled(c.mPreHook, enablePreHook) + c.setMenuEnabled(c.mPostHook, enablePostHook) } -func (c *trayController) runManualOperation(run func(stateKey, prefixedMenuItem, Profile, func())) { - prof, item, ok := c.selectedOperation() +func (c *trayController) startOperation(run func(operationHandle, Profile)) { + c.withActive(func(prof Profile, snapshot appSnapshot) { + c.startOperationAt(snapshot.configVersion, prof.profileKey(), snapshot.profiles[prof.profileKey()].generation, prof, run) + }) +} + +func (c *trayController) startOperationAt(version uint64, key stateKey, generation uint64, prof Profile, run func(operationHandle, Profile)) { + handle, ok := state.beginOperation(version, key, generation) if !ok { return } - key := prof.profileKey() - go run(key, item, prof, func() { c.finishProfile(key) }) + go func() { + defer func() { + if recovered := recover(); recovered != nil { + log.Printf("[%s] operation panicked: %v", prof.displayName(), recovered) + handle.fail("Operation panicked") + } + handle.done() + snapshot := state.snapshot() + if snapshot.cfg.loadErr == nil { + if _, prof, err := resolveProfileIndex(snapshot.cfg, key); err == nil { + c.startProbe(snapshot.cfg, snapshot.configVersion, snapshot.profiles[key].generation, prof, snapshot.profiles[key].lastBackup) + } + } + }() + run(handle, prof) + }() +} + +func (c *trayController) repoOp(status string, args ...string) { + c.startOperation(func(handle operationHandle, prof Profile) { + handle.status(status) + if msg, err := runBackend(handle, prof, args...); err != nil { + handle.fail(msg) + notifyError(args[0], msg) + } else { + notifySuccess(args[0]) + } + }) } func (c *trayController) handleClicks() { for { select { case <-c.mSchedule.ClickedCh: - c.runManualOperation(runScheduled) + c.startOperation(runScheduled) case <-c.mBackup.ClickedCh: - c.runManualOperation(runBackup) + c.startOperation(runBackup) case <-c.mCancel.ClickedCh: - cancelProfile(c.selectedProfile().profileKey()) + c.withActive(func(prof Profile, _ appSnapshot) { + state.cancelOperation(prof.profileKey()) + }) case <-c.mPrune.ClickedCh: - if prof, item, ok := c.selectedOperation(); ok && len(prof.Prune.Args) > 0 { - c.repoOp(prof, item, "Pruning repository...", forgetArgs(prof)...) - } + c.withActive(func(prof Profile, _ appSnapshot) { + if len(prof.Prune.Args) > 0 { + c.repoOp("Pruning repository...", forgetArgs(prof)...) + } + }) case <-c.mCheck.ClickedCh: - if prof, item, ok := c.selectedOperation(); ok { - c.repoOp(prof, item, "Checking repository...", checkArgs(prof)...) - } + c.withActive(func(prof Profile, _ appSnapshot) { + c.repoOp("Checking repository...", checkArgs(prof)...) + }) case <-c.mUnlock.ClickedCh: - if prof, item, ok := c.selectedOperation(); ok && prof.backend().supportsUnlock() { - c.repoOp(prof, item, "Unlocking repository...", "unlock") - } + c.withActive(func(prof Profile, _ appSnapshot) { + if prof.backend().supportsUnlock() { + c.repoOp("Unlocking repository...", "unlock") + } + }) case <-c.mInit.ClickedCh: - if prof, item, ok := c.selectedOperation(); ok { - c.repoOp(prof, item, "Initializing repository...", "init") - } + c.repoOp("Initializing repository...", "init") case <-c.mMount.ClickedCh: - prof := c.selectedProfile() - key := prof.profileKey() - if isProfileMounted(key) { - stopProfileMount(key) - } else { - go startMount(key, prof, c.mMount, func() { c.finishProfile(key) }) - } + c.withActive(func(prof Profile, snapshot appSnapshot) { + key := prof.profileKey() + profile, ok := snapshot.profiles[key] + if !ok { + return + } + if profile.mounted { + state.cancelMount(key) + } else { + go startMount(snapshot.configVersion, key, profile.generation, prof) + } + }) case <-c.mConsole.ClickedCh: - cfg := loadConfig() - if _, prof, ok := c.activeProfile(cfg); ok { - openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, "") - } + c.withActive(func(prof Profile, snapshot appSnapshot) { + openConsole(prof, len(snapshot.cfg.Profiles), snapshot.cfg.GUI.Terminal, "") + }) case <-c.mPreHook.ClickedCh: - prof := c.selectedProfile() - c.runManualHook(prof, prof.PreHook, "Pre-hook") + c.withActive(func(prof Profile, _ appSnapshot) { + c.runManualHook(prof.PreHook, "Pre-hook") + }) case <-c.mPostHook.ClickedCh: - prof := c.selectedProfile() - c.runManualHook(prof, prof.PostHook, "Post-hook") + c.withActive(func(prof Profile, _ appSnapshot) { + c.runManualHook(prof.PostHook, "Post-hook") + }) case <-c.mDownload.ClickedCh: - if !selfManagesBackend { - if c.selectedProfile().backend() == BackendRustic { - openFile("https://rustic.cli.rs/docs/installation.html") - } else { - openFile("https://restic.readthedocs.io/en/stable/020_installation.html") - } - } else if !isAnyBusy() { - c.hideActions() - go c.doDownload() - } + c.handleBackendDownload(false) case <-c.mUpdate.ClickedCh: - if selfManagesBackend && !isAnyBusy() { - c.hideActions() - go c.doDownload() - } + c.handleBackendDownload(true) case <-c.mSettings.ClickedCh: - if err := ensureConfigFile(); err != nil { - log.Printf("config: %v", err) - continue - } - openInEditor(configPath()) + c.withConfigFile(func() { + openInEditor(configPath()) + }) case <-c.mWebEditor.ClickedCh: - if err := ensureConfigFile(); err != nil { - log.Printf("config: %v", err) - continue - } - url, err := ensureWebEditor() - if err != nil { - log.Printf("webeditor: %v", err) - continue - } - openFile(url) + c.withConfigFile(func() { + url, err := ensureWebEditor() + if err != nil { + log.Printf("webeditor: %v", err) + return + } + openFile(url) + }) case <-c.mEnv.ClickedCh: - prof := c.selectedProfile() - if err := ensureEnvFile(prof.EnvFile); err != nil { - log.Printf("env: %v", err) - continue - } - openInEditor(prof.EnvFile) + c.withActive(func(prof Profile, _ appSnapshot) { + if err := ensureEnvFile(prof.EnvFile); err != nil { + log.Printf("env: %v", err) + return + } + openInEditor(prof.EnvFile) + }) case <-c.mFDA.ClickedCh: openFullDiskAccessSettings() case <-c.mFixPerms.ClickedCh: - cfg := loadConfig() - fixEnvFilePermissions(cfg.Profiles) - if envFilesInsecure(cfg.Profiles) { - c.mFixPerms.Show() - } else { - c.mFixPerms.Hide() - } - if !isAnyBusy() { - c.applyConfig() - } + snapshot := state.snapshot() + fixEnvFilePermissions(snapshot.cfg.Profiles) + c.requestConfigReload() case <-c.mLog.ClickedCh: - cfg := loadConfig() - if _, prof, ok := c.activeProfile(cfg); ok { - openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, logPath()) - } + c.withActive(func(prof Profile, snapshot appSnapshot) { + openConsole(prof, len(snapshot.cfg.Profiles), snapshot.cfg.GUI.Terminal, logPath()) + }) case <-c.mFolder.ClickedCh: openFile(configDir()) case <-c.mAbout.ClickedCh: - if v := latestKnownVersion.Load(); v != nil { - if applyUpdate(*v) { - c.stopScheduler() - stopAllMounts() - systray.Quit() + if latest := latestKnownVersion.Load(); latest != nil { + if applyUpdate(*latest) { + c.quit() } } else { openFile("https://tangled.org/devins.page/restray") } case <-c.mQuit.ClickedCh: - c.stopScheduler() - stopAllMounts() - systray.Quit() + c.quit() } } } -func (c *trayController) runManualHook(prof Profile, hook, name string) { +func (c *trayController) downloadSelected() { + c.withActive(func(prof Profile, snapshot appSnapshot) { + c.startDownload(snapshot.configVersion, prof.profileKey(), snapshot.profiles[prof.profileKey()].generation, prof) + }) +} + +func (c *trayController) runManualHook(hook, name string) { if hook == "" { return } - go func() { - if err := runHook(hook, prof); err != nil { + c.startOperation(func(handle operationHandle, current Profile) { + handle.status("Running " + strings.ToLower(name) + "...") + if err := runHookContext(handle.context(), handle.started, hook, current); err != nil { + handle.fail(name + " failed") notifyError(name, name+" failed") } else { notifySuccess(name) } - }() + }) } -func (c *trayController) startProfileSelectionHandlers() { +func (c *trayController) runScheduled(version uint64, key stateKey) { + snapshot := state.snapshot() + if snapshot.configVersion != version || snapshot.cfg.loadErr != nil { + return + } + _, prof, err := resolveProfileIndex(snapshot.cfg, key) + if err != nil || (!prof.Schedule.OnBattery && snapshot.batteryOn) { + return + } + c.startOperationAt(version, key, snapshot.profiles[key].generation, prof, runScheduled) +} + +func (c *trayController) startMenuHandlers() { for i := range c.profileItems { - idx := i go func() { - for range c.profileItems[idx].ClickedCh { - cfg := loadConfig() - if idx >= len(cfg.Profiles) { + for range c.profileItems[i].ClickedCh { + snapshot := state.snapshot() + if i >= len(snapshot.cfg.Profiles) { continue } - setActiveProfileKey(cfg.Profiles[idx].profileKey()) - c.markActiveProfile(idx, len(cfg.Profiles)) - c.setProfileTitle(cfg, idx) - c.applyProfileUI(cfg) + state.setActiveProfile(snapshot.cfg.Profiles[i].profileKey()) + c.prepareBackend(snapshot.configVersion) } }() } -} - -func (c *trayController) startProfileStatusHandlers() { for i := range c.mStatusItems { - idx := i go func() { - for range c.mStatusItems[idx].ClickedCh { - c.recheckProfile(idx) + for range c.mStatusItems[i].ClickedCh { + snapshot := state.snapshot() + if i >= len(snapshot.cfg.Profiles) || i >= maxProfiles { + continue + } + prof := snapshot.cfg.Profiles[i] + key := prof.profileKey() + profile, ok := snapshot.profiles[key] + if !ok || profile.operationActive { + continue + } + state.updateProfile(key, snapshot.configVersion, profile.generation, func(r *profileRecord) { + r.failure = "" + }) + c.startProbe(snapshot.cfg, snapshot.configVersion, profile.generation, prof, profile.lastBackup) } }() }