From 1df16f2ddc90271228a896b3bd76657a199a7856 Mon Sep 17 00:00:00 2001 From: intergrav Date: Thu, 2 Jul 2026 22:34:52 -0400 Subject: [PATCH] fix: improve profile state and refresh - refresh only affected profiles instead of everything which i noticed was very slow with my onedrive rclone repo - avoid duplicate profile state collisions - clean scheduler handling - fix up mount - as my onedrive rclone repo is slow, mount was cancelling before it could even load - various refactors --- cmd/restray/cli.go | 70 ++++++------ cmd/restray/config.go | 3 + cmd/restray/manage.go | 3 +- cmd/restray/operations.go | 235 +++++++++++++++++++------------------- cmd/restray/restic.go | 126 ++++++++++---------- cmd/restray/tray.go | 162 +++++++++++++++++--------- cmd/restray/update.go | 32 +++--- 7 files changed, 350 insertions(+), 281 deletions(-) diff --git a/cmd/restray/cli.go b/cmd/restray/cli.go index e655406..ee02d13 100644 --- a/cmd/restray/cli.go +++ b/cmd/restray/cli.go @@ -10,10 +10,10 @@ import ( "os/signal" "runtime" "strings" - "sync" "sync/atomic" "syscall" + "github.com/google/shlex" "github.com/robfig/cron/v3" "github.com/urfave/cli/v3" ) @@ -22,11 +22,20 @@ func resolveProfile(cfg Config, name string) (Profile, error) { if name == "" { return cfg.Profiles[0], nil } + var found Profile + matches := 0 for _, p := range cfg.Profiles { if p.displayName() == name { - return p, nil + found = p + matches++ } } + if matches == 1 { + return found, nil + } + if matches > 1 { + return Profile{}, fmt.Errorf("profile %q is ambiguous (matched %d profiles)", name, matches) + } var names []string for _, p := range cfg.Profiles { names = append(names, p.displayName()) @@ -34,6 +43,13 @@ func resolveProfile(cfg Config, name string) (Profile, error) { return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) } +func resolveProfileAt(cfg Config, idx int, name string) (Profile, error) { + if idx >= 0 && idx < len(cfg.Profiles) && cfg.Profiles[idx].displayName() == name { + return cfg.Profiles[idx], nil + } + return resolveProfile(cfg, name) +} + func editorCmd() string { if e := os.Getenv("VISUAL"); e != "" { return e @@ -63,7 +79,13 @@ func cliWeb(context.Context, *cli.Command) error { } func cliEdit(path string) error { - cmd := exec.Command(editorCmd(), path) + editor := editorCmd() + cmd := exec.Command(editor, path) + if runtime.GOOS != "windows" || !fileExists(editor) { + if parts, err := shlex.Split(editor); err == nil && len(parts) > 0 { + cmd = exec.Command(parts[0], append(parts[1:], path)...) + } + } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -181,19 +203,8 @@ func cliSchedule(prof Profile) error { interrupted.Store(true) }() - var ops []string - if prof.Schedule.BackupEnabled() { - ops = append(ops, "backup") - } - if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { - ops = append(ops, "prune") - } - if prof.Schedule.Check { - ops = append(ops, "check") - } - hookEnv := []string{ - "RESTRAY_OPERATIONS=" + strings.Join(ops, ","), + "RESTRAY_OPERATIONS=" + strings.Join(scheduledOps(prof), ","), "RESTRAY_SCHEDULED=true", } @@ -249,16 +260,16 @@ func cliSchedule(prof Profile) error { return nil } -func buildDaemonSchedule(wg *sync.WaitGroup) (*cron.Cron, error) { +func buildDaemonSchedule() (*cron.Cron, error) { sched := cron.New() - for _, prof := range loadConfig().Profiles { + for idx, prof := range loadConfig().Profiles { if prof.Schedule.Cron == "" { continue } name := prof.displayName() _, err := sched.AddFunc(prof.Schedule.Cron, func() { current := loadConfig() - prof, err := resolveProfile(current, name) + prof, err := resolveProfileAt(current, idx, name) if err != nil { log.Printf("[%s] skipping scheduled run: %v", name, err) return @@ -267,17 +278,13 @@ func buildDaemonSchedule(wg *sync.WaitGroup) (*cron.Cron, error) { log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) return } - wg.Add(1) - go func() { - defer wg.Done() - defer func() { - if r := recover(); r != nil { - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) - } - }() - log.Printf("[%s] running scheduled job", prof.displayName()) - cliSchedule(prof) + defer func() { + if r := recover(); r != nil { + log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) + } }() + log.Printf("[%s] running scheduled job", prof.displayName()) + cliSchedule(prof) }) if err != nil { return nil, fmt.Errorf("invalid cron expression %q for profile %q: %w", prof.Schedule.Cron, name, err) @@ -295,8 +302,7 @@ func cliDaemon(context.Context, *cli.Command) error { return cli.Exit("error: restic not found", 1) } - var wg sync.WaitGroup - sched, err := buildDaemonSchedule(&wg) + sched, err := buildDaemonSchedule() if err != nil { return cli.Exit(fmt.Sprintf("error: %v", err), 1) } @@ -310,7 +316,7 @@ func cliDaemon(context.Context, *cli.Command) error { if sig != syscall.SIGHUP { break } - next, err := buildDaemonSchedule(&wg) + next, err := buildDaemonSchedule() if err != nil { log.Printf("error: reload failed, keeping previous schedule: %v", err) continue @@ -322,8 +328,6 @@ func cliDaemon(context.Context, *cli.Command) error { } <-sched.Stop().Done() - log.Print("Waiting for running jobs to finish...") - wg.Wait() return nil } diff --git a/cmd/restray/config.go b/cmd/restray/config.go index a9d726a..bd08399 100644 --- a/cmd/restray/config.go +++ b/cmd/restray/config.go @@ -111,6 +111,9 @@ func parseEnvFile(path string) (map[string]string, error) { } env[key] = val } + if err := scanner.Err(); err != nil { + return nil, err + } return env, nil } diff --git a/cmd/restray/manage.go b/cmd/restray/manage.go index 1bd3235..a63a678 100644 --- a/cmd/restray/manage.go +++ b/cmd/restray/manage.go @@ -40,7 +40,8 @@ func managedRestic() string { } func resetResticPath() { - resticOnce = sync.Once{} + resticMu.Lock() + defer resticMu.Unlock() resticPath = "" resticManaged = false } diff --git a/cmd/restray/operations.go b/cmd/restray/operations.go index 7cbbeee..776f559 100644 --- a/cmd/restray/operations.go +++ b/cmd/restray/operations.go @@ -48,12 +48,12 @@ func streamStderr(pipe io.Reader, mStatus prefixedMenuItem) *stderrLogger { return sl } -func runResticOnce(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { +func runResticOnce(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { cmd := resticCmd(prof, args...) var stdout strings.Builder cmd.Stdout = &stdout stderrPipe, _ := cmd.StderrPipe() - setProfileBusyCmd(key, cmd) + setProfileBusyCmd(idx, cmd) if err := cmd.Start(); err != nil { log.Printf("[%s] start failed: %v", prof.displayName(), err) @@ -81,33 +81,33 @@ func retryLockEnabled(prof Profile) bool { return prof.RetryLock != "" && prof.RetryLock != "0" } -func retryUnlock(key profileKey, prof Profile, mStatus prefixedMenuItem) { +func retryUnlock(idx profileIndex, prof Profile, mStatus prefixedMenuItem) { log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) mStatus.SetTitle("Unlocking repository...") - runResticOnce(key, prof, mStatus, "unlock") + runResticOnce(idx, prof, mStatus, "unlock") } -func runRestic(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { +func runRestic(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { if retryLockEnabled(prof) { args = append(args, "--retry-lock", prof.RetryLock) } - msg, err := runResticOnce(key, prof, mStatus, args...) + msg, err := runResticOnce(idx, prof, mStatus, args...) if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { - retryUnlock(key, prof, mStatus) - msg, err = runResticOnce(key, prof, mStatus, args...) + retryUnlock(idx, prof, mStatus) + msg, err = runResticOnce(idx, prof, mStatus, args...) } return msg, err } -func doBackupOnce(key profileKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { +func doBackupOnce(idx profileIndex, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { cmd := resticCmd(prof, args...) stdoutPipe, _ := cmd.StdoutPipe() stderrPipe, _ := cmd.StderrPipe() - setProfileBusyCmd(key, cmd) + setProfileBusyCmd(idx, cmd) if err := cmd.Start(); err != nil { log.Printf("[%s] start failed: %v", prof.displayName(), err) - setProfileFailed(key, err.Error()) + setProfileFailed(idx, err.Error()) return false, -1 } @@ -152,87 +152,42 @@ func doBackupOnce(key profileKey, mStatus prefixedMenuItem, prof Profile, args [ log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) return true, 3 } - setProfileFailed(key, msg) + setProfileFailed(idx, msg) return false, code } log.Printf("[%s] done: backup", prof.displayName()) return true, 0 } -func doBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { +func doBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { args := backupArgs(prof, scheduled, "--json") - ok, code := doBackupOnce(key, mStatus, prof, args) + ok, code := doBackupOnce(idx, mStatus, prof, args) if !ok && code == 11 && retryLockEnabled(prof) { - retryUnlock(key, prof, mStatus) + retryUnlock(idx, prof, mStatus) mStatus.SetTitle("Backing up...") - setProfileFailed(key, "") - ok, code = doBackupOnce(key, mStatus, prof, args) + setProfileFailed(idx, "") + ok, code = doBackupOnce(idx, mStatus, prof, args) } if ok { - setLastBackup(key, time.Now()) + setLastBackup(idx, time.Now()) } return ok, code } -func runWithHooks(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func(), - label, opsEnv string, op func() (bool, string)) { - if !acquireProfile(key) { - return - } - setProfileFailed(key, "") - defer onDone() - defer releaseProfile(key) - - hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=true"} - - if prof.PreHook != "" { - mStatus.SetTitle("Running pre-hook...") - if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { - setProfileFailed(key, "Pre-hook failed") - notifyError(label, "Pre-hook failed") - return - } - } - - ok, successMsg := op() - - if ok { - if successMsg != "" { - notifyError(label, successMsg) - } else { - notifySuccess(label) - } - } else { - notifyError(label, getProfileFailStatus(key)) - } - - if prof.PostHook != "" { - mStatus.SetTitle("Running post-hook...") - postEnv := hookEnv - if !ok { - postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key)) - } - if err := runHook(prof.PostHook, prof, postEnv...); err != nil { - setProfileFailed(key, "Post-hook failed") - notifyError(label, "Post-hook failed") - } - } -} - -func runBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { +func runBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { if p, _ := findRestic(); p == "" { return } - if !acquireProfile(key) { + if !acquireProfile(idx) { return } - setProfileFailed(key, "") + setProfileFailed(idx, "") defer onDone() - defer releaseProfile(key) + defer releaseProfile(idx) mStatus.SetTitle("Backing up...") - ok, code := doBackup(key, mStatus, prof, false) + ok, code := doBackup(idx, mStatus, prof, false) if ok { if code == 3 { notifyError("Backup", "Completed with warnings: some files could not be read") @@ -240,7 +195,7 @@ func runBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone fu notifySuccess("Backup") } } else { - notifyError("Backup", getProfileFailStatus(key)) + notifyError("Backup", getProfileFailStatus(idx)) } } @@ -285,11 +240,7 @@ func runHook(hook string, prof Profile, extraEnv ...string) error { return err } -func runScheduled(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { - if p, _ := findRestic(); p == "" { - return - } - +func scheduledOps(prof Profile) []string { var ops []string if prof.Schedule.BackupEnabled() { ops = append(ops, "backup") @@ -300,31 +251,75 @@ func runScheduled(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone if prof.Schedule.Check { ops = append(ops, "check") } + return ops +} - runWithHooks(key, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { - if prof.Schedule.BackupEnabled() { - mStatus.SetTitle("Backing up...") - if ok, _ := doBackup(key, mStatus, prof, true); !ok { - return false, "" - } +func runScheduled(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { + if p, _ := findRestic(); p == "" { + return + } + if !acquireProfile(idx) { + return + } + setProfileFailed(idx, "") + defer onDone() + defer releaseProfile(idx) + + hookEnv := []string{ + "RESTRAY_OPERATIONS=" + strings.Join(scheduledOps(prof), ","), + "RESTRAY_SCHEDULED=true", + } + + if prof.PreHook != "" { + mStatus.SetTitle("Running pre-hook...") + if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { + setProfileFailed(idx, "Pre-hook failed") + notifyError("Schedule", "Pre-hook failed") + return } - failed := false - if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { - mStatus.SetTitle("Pruning repository...") - if msg, err := runRestic(key, prof, mStatus, forgetArgs(prof)...); err != nil { - setProfileFailed(key, msg) - failed = true - } + } + + failed := false + skipRemaining := false + if prof.Schedule.BackupEnabled() { + mStatus.SetTitle("Backing up...") + if ok, _ := doBackup(idx, mStatus, prof, true); !ok { + failed = true + skipRemaining = true } - if prof.Schedule.Check { - mStatus.SetTitle("Checking repository...") - if msg, err := runRestic(key, prof, mStatus, checkArgs(prof)...); err != nil { - setProfileFailed(key, msg) - failed = true - } + } + if !skipRemaining && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { + mStatus.SetTitle("Pruning repository...") + if msg, err := runRestic(idx, prof, mStatus, forgetArgs(prof)...); err != nil { + setProfileFailed(idx, msg) + failed = true } - return !failed, "" - }) + } + if !skipRemaining && prof.Schedule.Check { + mStatus.SetTitle("Checking repository...") + if msg, err := runRestic(idx, prof, mStatus, checkArgs(prof)...); err != nil { + setProfileFailed(idx, msg) + failed = true + } + } + + if failed { + notifyError("Schedule", getProfileFailStatus(idx)) + } else { + notifySuccess("Schedule") + } + + if prof.PostHook != "" { + mStatus.SetTitle("Running post-hook...") + postEnv := hookEnv + if failed { + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) + } + if err := runHook(prof.PostHook, prof, postEnv...); err != nil { + setProfileFailed(idx, "Post-hook failed") + notifyError("Schedule", "Post-hook failed") + } + } } func backupArgs(prof Profile, scheduled bool, extra ...string) []string { @@ -348,13 +343,13 @@ func checkArgs(prof Profile) []string { return append([]string{"check"}, prof.Check.Args...) } -func isProfileMounted(key profileKey) bool { +func isProfileMounted(idx profileIndex) bool { state.mu.Lock() defer state.mu.Unlock() - return state.mountCmds[key] != nil + return state.mountCmds[idx] != nil } -func startMount(key profileKey, prof Profile, mMount *systray.MenuItem, onDone func()) { +func startMount(idx profileIndex, prof Profile, mMount *systray.MenuItem, onDone func()) { dir, err := os.MkdirTemp("", "restray-mount-") if err != nil { return @@ -374,7 +369,7 @@ func startMount(key profileKey, prof Profile, mMount *systray.MenuItem, onDone f } state.mu.Lock() - state.mountCmds[key] = cmd + state.mountCmds[idx] = cmd state.mu.Unlock() mMount.SetTitle("Unmount") @@ -383,28 +378,36 @@ func startMount(key profileKey, prof Profile, mMount *systray.MenuItem, onDone f go func() { done <- cmd.Wait() }() mounted := false - for i := 0; i < 20; i++ { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + timeout := time.NewTimer(30 * time.Second) + defer timeout.Stop() +wait: + for { select { case <-done: - goto cleanup - case <-time.After(250 * time.Millisecond): - } - entries, _ := os.ReadDir(dir) - if len(entries) > 0 { - mounted = true - break + break wait + case <-timeout.C: + interruptProcess(cmd.Process) + <-done + break wait + case <-ticker.C: + entries, _ := os.ReadDir(dir) + if len(entries) > 0 { + mounted = true + break wait + } } } if mounted { openFile(dir) <-done } -cleanup: state.mu.Lock() - delete(state.mountCmds, key) - stopped := state.mountStopping[key] - delete(state.mountStopping, key) + delete(state.mountCmds, idx) + stopped := state.mountStopping[idx] + delete(state.mountStopping, idx) state.mu.Unlock() os.Remove(dir) @@ -415,11 +418,11 @@ cleanup: onDone() } -func stopProfileMount(key profileKey) { +func stopProfileMount(idx profileIndex) { state.mu.Lock() defer state.mu.Unlock() - if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil { - state.mountStopping[key] = true + if cmd := state.mountCmds[idx]; cmd != nil && cmd.Process != nil { + state.mountStopping[idx] = true interruptProcess(cmd.Process) } } @@ -427,9 +430,9 @@ func stopProfileMount(key profileKey) { func stopAllMounts() { state.mu.Lock() defer state.mu.Unlock() - for key, cmd := range state.mountCmds { + for idx, cmd := range state.mountCmds { if cmd != nil && cmd.Process != nil { - state.mountStopping[key] = true + state.mountStopping[idx] = true interruptProcess(cmd.Process) } } diff --git a/cmd/restray/restic.go b/cmd/restray/restic.go index ed8860d..74b3d97 100644 --- a/cmd/restray/restic.go +++ b/cmd/restray/restic.go @@ -17,39 +17,42 @@ import ( var resticBuiltinPath string var ( - resticOnce sync.Once + resticMu sync.Mutex resticPath string resticManaged bool ) func findRestic() (path string, managed bool) { - resticOnce.Do(func() { - if p, err := exec.LookPath("restic"); err == nil { - resticPath = p - return - } - if p := findResticFromShell(); p != "" { - resticPath = p - return - } - if resticBuiltinPath != "" { - if _, err := os.Stat(resticBuiltinPath); err == nil { - resticPath = resticBuiltinPath - return - } - } - if p := managedRestic(); p != "" { - resticPath = p - resticManaged = true - return - } - if p := bundledRestic(); p != "" { - resticPath = p - } - }) + resticMu.Lock() + defer resticMu.Unlock() + if resticPath != "" { + return resticPath, resticManaged + } + resticPath, resticManaged = searchRestic() return resticPath, resticManaged } +func searchRestic() (string, bool) { + if p, err := exec.LookPath("restic"); err == nil { + return p, false + } + if p := findResticFromShell(); p != "" { + return p, false + } + if resticBuiltinPath != "" { + if _, err := os.Stat(resticBuiltinPath); err == nil { + return resticBuiltinPath, false + } + } + if p := managedRestic(); p != "" { + return p, true + } + if p := bundledRestic(); p != "" { + return p, false + } + return "", false +} + func bundledRestic() string { if runtime.GOOS != "darwin" { return "" @@ -177,43 +180,43 @@ func repoStatusAndLastBackup(prof Profile) (repoResult, time.Time) { return rr, snaps[0].Time } -type profileKey = string +type profileIndex = int type appState struct { mu sync.Mutex - busyProfiles map[profileKey]*exec.Cmd + busyProfiles map[profileIndex]*exec.Cmd onBusyChanged func(bool) - mountCmds map[profileKey]*exec.Cmd - mountStopping map[profileKey]bool + mountCmds map[profileIndex]*exec.Cmd + mountStopping map[profileIndex]bool notifications string - failStatus map[profileKey]string - lastBackup map[profileKey]time.Time + failStatus map[profileIndex]string + lastBackup map[profileIndex]time.Time } var state = appState{ - busyProfiles: make(map[profileKey]*exec.Cmd), - mountCmds: make(map[profileKey]*exec.Cmd), - mountStopping: make(map[profileKey]bool), - failStatus: make(map[profileKey]string), - lastBackup: make(map[profileKey]time.Time), + busyProfiles: make(map[profileIndex]*exec.Cmd), + mountCmds: make(map[profileIndex]*exec.Cmd), + mountStopping: make(map[profileIndex]bool), + failStatus: make(map[profileIndex]string), + lastBackup: make(map[profileIndex]time.Time), } -func setLastBackup(key profileKey, t time.Time) { +func setLastBackup(idx profileIndex, t time.Time) { state.mu.Lock() - state.lastBackup[key] = t + state.lastBackup[idx] = t state.mu.Unlock() } -func getLastBackup(key profileKey) time.Time { +func getLastBackup(idx profileIndex) time.Time { state.mu.Lock() defer state.mu.Unlock() - return state.lastBackup[key] + return state.lastBackup[idx] } -func isProfileBusy(key profileKey) bool { +func isProfileBusy(idx profileIndex) bool { state.mu.Lock() defer state.mu.Unlock() - _, ok := state.busyProfiles[key] + _, ok := state.busyProfiles[idx] return ok } @@ -223,49 +226,50 @@ func isAnyBusy() bool { return len(state.busyProfiles) > 0 } -func releaseProfile(key profileKey) { +func releaseProfile(idx profileIndex) { state.mu.Lock() - applyBusy(key, false) + wasBusy := len(state.busyProfiles) > 0 + delete(state.busyProfiles, idx) + nowBusy := len(state.busyProfiles) > 0 + cb := state.onBusyChanged + state.mu.Unlock() + finishBusyChange(wasBusy, nowBusy, cb) } -func acquireProfile(key profileKey) bool { +func acquireProfile(idx profileIndex) bool { state.mu.Lock() - if _, busy := state.busyProfiles[key]; busy { + if _, busy := state.busyProfiles[idx]; busy { state.mu.Unlock() return false } - applyBusy(key, true) + wasBusy := len(state.busyProfiles) > 0 + state.busyProfiles[idx] = nil + nowBusy := len(state.busyProfiles) > 0 + cb := state.onBusyChanged + state.mu.Unlock() + finishBusyChange(wasBusy, nowBusy, cb) return true } -func applyBusy(key profileKey, active bool) { - wasBusy := len(state.busyProfiles) > 0 - if active { - state.busyProfiles[key] = nil - } else { - delete(state.busyProfiles, key) - } - nowBusy := len(state.busyProfiles) > 0 +func finishBusyChange(wasBusy, nowBusy bool, cb func(bool)) { if nowBusy && !wasBusy { setIconAnimated("busy") } - cb := state.onBusyChanged - state.mu.Unlock() if cb != nil { cb(nowBusy) } } -func setProfileBusyCmd(key profileKey, cmd *exec.Cmd) { +func setProfileBusyCmd(idx profileIndex, cmd *exec.Cmd) { state.mu.Lock() defer state.mu.Unlock() - state.busyProfiles[key] = cmd + state.busyProfiles[idx] = cmd } -func cancelProfile(key profileKey) { +func cancelProfile(idx profileIndex) { state.mu.Lock() defer state.mu.Unlock() - if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { + if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { interruptProcess(cmd.Process) } } diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go index 89442eb..8b30b87 100644 --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -66,20 +66,20 @@ func getActiveProfile() int { return activeProfile } -func setProfileFailed(key profileKey, status string) { +func setProfileFailed(idx profileIndex, status string) { state.mu.Lock() if status == "" { - delete(state.failStatus, key) + delete(state.failStatus, idx) } else { - state.failStatus[key] = status + state.failStatus[idx] = status } state.mu.Unlock() } -func getProfileFailStatus(key profileKey) string { +func getProfileFailStatus(idx profileIndex) string { state.mu.Lock() defer state.mu.Unlock() - return state.failStatus[key] + return state.failStatus[idx] } func anyError() bool { @@ -108,16 +108,16 @@ func profilePrefix(cfg Config, prof Profile) string { func unreachableRecovered(cfg Config) bool { profileMu.Lock() - unreachable := make(map[string]bool) + var unreachable []Profile for i, p := range profileStates { if p.errMsg == "" && p.repoErr != "" && i < len(cfg.Profiles) { - unreachable[cfg.Profiles[i].displayName()] = true + unreachable = append(unreachable, cfg.Profiles[i]) } } profileMu.Unlock() - for _, prof := range cfg.Profiles { - if unreachable[prof.displayName()] && repoStatus(prof).errMsg == "" { + for _, prof := range unreachable { + if repoStatus(prof).errMsg == "" { return true } } @@ -216,6 +216,7 @@ func onReady() { sched := cron.New() var applyConfig func() var applyMu sync.Mutex + pendingFullApply := false state.onBusyChanged = func(busy bool) { if busy { @@ -306,7 +307,7 @@ func onReady() { paused := !prof.Schedule.OnBattery && onBatteryPower() if prof.Schedule.Cron == "" { if cfg.GUI.ScheduleDisplay == "last" { - last := formatLastBackup(getLastBackup(prof.displayName())) + last := formatLastBackup(getLastBackup(i)) return prefix + "Unscheduled, last " + last } return prefix + "Unscheduled" @@ -328,7 +329,7 @@ func onReady() { } return prefix + prof.Schedule.Cron case "last": - last := formatLastBackup(getLastBackup(prof.displayName())) + last := formatLastBackup(getLastBackup(i)) if next != "" { return prefix + next + ", last " + last } @@ -346,9 +347,8 @@ func onReady() { updateAllStatusItems := func(cfg Config) { mGlobalStatus.Hide() for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { - key := cfg.Profiles[i].displayName() - if !isProfileBusy(key) { - if failMsg := getProfileFailStatus(key); failMsg != "" { + if !isProfileBusy(i) { + if failMsg := getProfileFailStatus(i); failMsg != "" { statusItem(i).SetTitle(failMsg) } else { mStatusItems[i].SetTitle(profileStatusText(cfg, i)) @@ -371,7 +371,6 @@ func onReady() { ps := profileStates[idx] profileMu.Unlock() prof := cfg.Profiles[idx] - key := prof.displayName() createOrEdit(mEnv, "Env File", prof.EnvFile) if envFilesInsecure(cfg.Profiles) { @@ -388,13 +387,13 @@ func onReady() { if runtime.GOOS == "windows" { mMount.Hide() - } else if isProfileMounted(key) { + } else if isProfileMounted(idx) { mMount.SetTitle("Unmount") } else { mMount.SetTitle("Mount") } - if isProfileBusy(key) { + if isProfileBusy(idx) { mCancel.Show() mRepo.Enable() mBackup.Disable() @@ -427,10 +426,78 @@ func onReady() { } } + probeProfileState := func(cfg Config, i int, prof Profile) profileState { + var ps profileState + ps.errMsg = prof.configError() + if ps.errMsg == "" { + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() + var rs repoResult + if wantsLast { + var last time.Time + rs, last = repoStatusAndLastBackup(prof) + if !last.IsZero() { + setLastBackup(i, last) + } + } else { + rs = repoStatus(prof) + } + ps.repoErr = rs.errMsg + ps.needsInit = rs.needsInit + } + return ps + } + + refreshProfile := func(idx int) { + applyMu.Lock() + cfg := loadConfig() + if idx < 0 || idx >= len(cfg.Profiles) || idx >= len(profileStates) || idx >= maxProfiles { + applyMu.Unlock() + applyConfig() + return + } + defer applyMu.Unlock() + prof := cfg.Profiles[idx] + mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") + ps := probeProfileState(cfg, idx, prof) + profileMu.Lock() + if idx < len(profileStates) { + profileStates[idx] = ps + } + profileMu.Unlock() + if !isProfileBusy(idx) { + if failMsg := getProfileFailStatus(idx); failMsg != "" { + statusItem(idx).SetTitle(failMsg) + } else { + mStatusItems[idx].SetTitle(profileStatusText(cfg, idx)) + } + } + if !isAnyBusy() { + if anyError() { + setIconAnimated("fail") + } else { + setIconAnimated("idle") + } + } + applyProfileUI(cfg) + } + + finishProfile := func(idx int) { + applyMu.Lock() + pending := pendingFullApply + pendingFullApply = false + applyMu.Unlock() + if pending { + applyConfig() + return + } + refreshProfile(idx) + } + applyConfig = func() { applyMu.Lock() defer applyMu.Unlock() if isAnyBusy() { + pendingFullApply = true cfg := loadConfig() if applyIconMode(cfg.GUI.Icon) { refreshIcon() @@ -447,6 +514,7 @@ func onReady() { applyProfileUI(cfg) return } + pendingFullApply = false cfg := loadConfig() applyIconMode(cfg.GUI.Icon) state.mu.Lock() @@ -514,29 +582,13 @@ func onReady() { wg.Add(1) go func(i int, prof Profile) { defer wg.Done() - var ps profileState - ps.errMsg = prof.configError() - if ps.errMsg == "" { - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.displayName()).IsZero() - var rs repoResult - if wantsLast { - var last time.Time - rs, last = repoStatusAndLastBackup(prof) - if !last.IsZero() { - setLastBackup(prof.displayName(), last) - } - } else { - rs = repoStatus(prof) - } - ps.repoErr = rs.errMsg - ps.needsInit = rs.needsInit - } + ps := probeProfileState(cfg, i, prof) if ps.errMsg == "" && prof.Schedule.Cron != "" { name := prof.displayName() eid, err := sched.AddFunc(prof.Schedule.Cron, func() { current := loadConfig() - prof, err := resolveProfile(current, name) + prof, err := resolveProfileAt(current, i, name) if err != nil { log.Printf("[%s] skipping scheduled backup: %v", name, err) return @@ -551,7 +603,7 @@ func onReady() { log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) } }() - runScheduled(prof.displayName(), statusItem(i), prof, applyConfig) + runScheduled(i, statusItem(i), prof, func() { finishProfile(i) }) }() }) if err != nil { @@ -659,21 +711,20 @@ func onReady() { repoOp := func(status string, args ...string) { idx := getActiveProfile() prof := selectedProfile() - key := prof.displayName() - if idx >= maxProfiles || isProfileBusy(key) { + if idx >= maxProfiles || isProfileBusy(idx) { return } ms := statusItem(idx) go func() { - if !acquireProfile(key) { + if !acquireProfile(idx) { return } - setProfileFailed(key, "") - defer applyConfig() - defer releaseProfile(key) + setProfileFailed(idx, "") + defer finishProfile(idx) + defer releaseProfile(idx) ms.SetTitle(status) - if msg, err := runRestic(key, prof, ms, args...); err != nil { - setProfileFailed(key, msg) + if msg, err := runRestic(idx, prof, ms, args...); err != nil { + setProfileFailed(idx, msg) notifyError(args[0], msg) } else { notifySuccess(args[0]) @@ -687,19 +738,18 @@ func onReady() { case <-mSchedule.ClickedCh: idx := getActiveProfile() prof := selectedProfile() - key := prof.displayName() - if idx < maxProfiles && !isProfileBusy(key) { - go runScheduled(key, statusItem(idx), prof, applyConfig) + if idx < maxProfiles && !isProfileBusy(idx) { + go runScheduled(idx, statusItem(idx), prof, func() { finishProfile(idx) }) } case <-mBackup.ClickedCh: idx := getActiveProfile() prof := selectedProfile() - key := prof.displayName() - if idx < maxProfiles && !isProfileBusy(key) { - go runBackup(key, statusItem(idx), prof, applyConfig) + if idx < maxProfiles && !isProfileBusy(idx) { + go runBackup(idx, statusItem(idx), prof, func() { finishProfile(idx) }) } case <-mCancel.ClickedCh: - cancelProfile(selectedProfile().displayName()) + idx := getActiveProfile() + cancelProfile(idx) case <-mPrune.ClickedCh: prof := selectedProfile() if len(prof.Prune.Args) > 0 { @@ -712,12 +762,12 @@ func onReady() { case <-mInit.ClickedCh: repoOp("Initializing repository...", "init") case <-mMount.ClickedCh: + idx := getActiveProfile() prof := selectedProfile() - key := prof.displayName() - if isProfileMounted(key) { - stopProfileMount(key) + if isProfileMounted(idx) { + stopProfileMount(idx) } else { - go startMount(key, prof, mMount, applyConfig) + go startMount(idx, prof, mMount, func() { finishProfile(idx) }) } case <-mConsole.ClickedCh: cfg := loadConfig() @@ -807,7 +857,7 @@ func onReady() { } }() - if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().General.UpdatesEnabled() { + if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { go func() { check := func() { latest, err := latestVersion() diff --git a/cmd/restray/update.go b/cmd/restray/update.go index 73424c2..3bcae9c 100644 --- a/cmd/restray/update.go +++ b/cmd/restray/update.go @@ -49,7 +49,7 @@ func latestVersion() (string, error) { for _, e := range feed.Entries { t := strings.TrimSpace(strings.TrimPrefix(e.Title, "[Tag]")) v := strings.TrimPrefix(t, "v") - if !looksLikeVersion(v) { + if _, ok := parseVersion(v); !ok { continue } if best == "" || compareVersion(v, best) > 0 { @@ -62,27 +62,31 @@ func latestVersion() (string, error) { return best, nil } -func looksLikeVersion(v string) bool { +func parseVersion(v string) ([3]int, bool) { parts := strings.Split(v, ".") if len(parts) != 3 { - return false + return [3]int{}, false } - for _, p := range parts { - if _, err := strconv.Atoi(p); err != nil { - return false + var nums [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return [3]int{}, false } + nums[i] = n } - return true + return nums, true } func compareVersion(a, b string) int { - pa := strings.Split(a, ".") - pb := strings.Split(b, ".") - for i := 0; i < 3; i++ { - na, _ := strconv.Atoi(pa[i]) - nb, _ := strconv.Atoi(pb[i]) - if na != nb { - return na - nb + pa, okA := parseVersion(a) + pb, okB := parseVersion(b) + if !okA || !okB { + return 0 + } + for i := range pa { + if pa[i] != pb[i] { + return pa[i] - pb[i] } } return 0 -- 2.51.2