powerful but friendly backup program that runs in your tray, powered by restic devins.page/restray
go restic system-tray
2

Configure Feed

Select the types of activity you want to include in your feed.

fix: key per-profile runtime state by name, not slice index

index shifts when profiles are
reordered/added/removed while the app is running, causing state to attach to the wrong profile.

also i merged repoStatus + last-backup query into one restic call (snapshots --latest 1) when schedule_display is "last", cutting redundant round-trip on startup for slow backends. (for example, i was having this issue with my rclone onedrive backend taking forever to query and sometimes failing even)

intergrav (Jul 2, 2026, 3:31 AM EDT) e298548f ae99e163

+174 -167
+7 -7
cmd/restray/cli.go
··· 18 18 "github.com/urfave/cli/v3" 19 19 ) 20 20 21 - func resolveProfile(cfg Config, name string) (int, Profile, error) { 21 + func resolveProfile(cfg Config, name string) (Profile, error) { 22 22 if name == "" { 23 - return 0, cfg.Profiles[0], nil 23 + return cfg.Profiles[0], nil 24 24 } 25 - for i, p := range cfg.Profiles { 25 + for _, p := range cfg.Profiles { 26 26 if p.displayName() == name { 27 - return i, p, nil 27 + return p, nil 28 28 } 29 29 } 30 30 var names []string 31 31 for _, p := range cfg.Profiles { 32 32 names = append(names, p.displayName()) 33 33 } 34 - return 0, Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) 34 + return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) 35 35 } 36 36 37 37 func editorCmd() string { ··· 243 243 name := prof.displayName() 244 244 _, err := sched.AddFunc(prof.Schedule.Cron, func() { 245 245 current := loadConfig() 246 - _, prof, err := resolveProfile(current, name) 246 + prof, err := resolveProfile(current, name) 247 247 if err != nil { 248 248 log.Printf("[%s] skipping scheduled run: %v", name, err) 249 249 return ··· 322 322 323 323 func profileAction(fn func(Profile) error) cli.ActionFunc { 324 324 return func(_ context.Context, cmd *cli.Command) error { 325 - _, prof, err := resolveProfile(loadConfig(), cmd.String("profile")) 325 + prof, err := resolveProfile(loadConfig(), cmd.String("profile")) 326 326 if err != nil { 327 327 return cli.Exit(fmt.Sprintf("error: %v", err), 1) 328 328 }
+54 -54
cmd/restray/operations.go
··· 47 47 return sl 48 48 } 49 49 50 - func runResticOnce(idx int, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 50 + func runResticOnce(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 51 51 cmd := resticCmd(prof, args...) 52 52 var stdout strings.Builder 53 53 cmd.Stdout = &stdout 54 54 stderrPipe, _ := cmd.StderrPipe() 55 - setProfileBusyCmd(idx, cmd) 55 + setProfileBusyCmd(key, cmd) 56 56 57 57 if err := cmd.Start(); err != nil { 58 58 log.Printf("[%s] start failed: %v", prof.displayName(), err) ··· 80 80 return prof.RetryLock != "" && prof.RetryLock != "0" 81 81 } 82 82 83 - func retryUnlock(idx int, prof Profile, mStatus prefixedMenuItem) { 83 + func retryUnlock(key profileKey, prof Profile, mStatus prefixedMenuItem) { 84 84 log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 85 85 mStatus.SetTitle("Unlocking repository...") 86 - runResticOnce(idx, prof, mStatus, "unlock") 86 + runResticOnce(key, prof, mStatus, "unlock") 87 87 } 88 88 89 - func runRestic(idx int, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 89 + func runRestic(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 90 90 if retryLockEnabled(prof) { 91 91 args = append(args, "--retry-lock", prof.RetryLock) 92 92 } 93 - msg, err := runResticOnce(idx, prof, mStatus, args...) 93 + msg, err := runResticOnce(key, prof, mStatus, args...) 94 94 if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 95 - retryUnlock(idx, prof, mStatus) 96 - msg, err = runResticOnce(idx, prof, mStatus, args...) 95 + retryUnlock(key, prof, mStatus) 96 + msg, err = runResticOnce(key, prof, mStatus, args...) 97 97 } 98 98 if killedBySystem(err) { 99 99 log.Printf("[%s] %s killed by system, retrying once", prof.displayName(), args[0]) 100 - msg, err = runResticOnce(idx, prof, mStatus, args...) 100 + msg, err = runResticOnce(key, prof, mStatus, args...) 101 101 } 102 102 return msg, err 103 103 } ··· 115 115 } 116 116 } 117 117 118 - func doBackupOnce(idx int, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 118 + func doBackupOnce(key profileKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 119 119 cmd := resticCmd(prof, args...) 120 120 stdoutPipe, _ := cmd.StdoutPipe() 121 121 stderrPipe, _ := cmd.StderrPipe() 122 - setProfileBusyCmd(idx, cmd) 122 + setProfileBusyCmd(key, cmd) 123 123 124 124 if err := cmd.Start(); err != nil { 125 125 log.Printf("[%s] start failed: %v", prof.displayName(), err) 126 - setProfileFailed(idx, err.Error()) 126 + setProfileFailed(key, err.Error()) 127 127 return false, -1 128 128 } 129 129 ··· 168 168 log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) 169 169 return true, 3 170 170 } 171 - setProfileFailed(idx, msg) 171 + setProfileFailed(key, msg) 172 172 return false, code 173 173 } 174 174 log.Printf("[%s] done: backup", prof.displayName()) 175 175 return true, 0 176 176 } 177 177 178 - func doBackup(idx int, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 178 + func doBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 179 179 args := backupArgs(prof, scheduled, "--json") 180 180 181 - ok, code := doBackupOnce(idx, mStatus, prof, args) 181 + ok, code := doBackupOnce(key, mStatus, prof, args) 182 182 if !ok && code == 11 && retryLockEnabled(prof) { 183 - retryUnlock(idx, prof, mStatus) 183 + retryUnlock(key, prof, mStatus) 184 184 mStatus.SetTitle("Backing up...") 185 - setProfileFailed(idx, "") 186 - ok, code = doBackupOnce(idx, mStatus, prof, args) 185 + setProfileFailed(key, "") 186 + ok, code = doBackupOnce(key, mStatus, prof, args) 187 187 } 188 188 if !ok && code == -1 { 189 189 log.Printf("[%s] backup killed by system, retrying once", prof.displayName()) 190 190 mStatus.SetTitle("Backing up...") 191 - setProfileFailed(idx, "") 192 - ok, code = doBackupOnce(idx, mStatus, prof, args) 191 + setProfileFailed(key, "") 192 + ok, code = doBackupOnce(key, mStatus, prof, args) 193 193 } 194 194 if ok { 195 - setLastBackup(idx, time.Now()) 195 + setLastBackup(key, time.Now()) 196 196 } 197 197 return ok, code 198 198 } 199 199 200 - func runWithHooks(idx int, mStatus prefixedMenuItem, prof Profile, onDone func(), 200 + func runWithHooks(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func(), 201 201 label, opsEnv string, op func() (bool, string)) { 202 - if !acquireProfile(idx) { 202 + if !acquireProfile(key) { 203 203 return 204 204 } 205 - setProfileFailed(idx, "") 205 + setProfileFailed(key, "") 206 206 defer onDone() 207 - defer setProfileBusy(idx, false) 207 + defer releaseProfile(key) 208 208 209 209 hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=true"} 210 210 211 211 if prof.PreHook != "" { 212 212 mStatus.SetTitle("Running pre-hook...") 213 213 if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 214 - setProfileFailed(idx, "Pre-hook failed") 214 + setProfileFailed(key, "Pre-hook failed") 215 215 notifyError(label, "Pre-hook failed") 216 216 return 217 217 } ··· 226 226 notifySuccess(label) 227 227 } 228 228 } else { 229 - notifyError(label, getProfileFailStatus(idx)) 229 + notifyError(label, getProfileFailStatus(key)) 230 230 } 231 231 232 232 if prof.PostHook != "" { 233 233 mStatus.SetTitle("Running post-hook...") 234 234 postEnv := hookEnv 235 235 if !ok { 236 - postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) 236 + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key)) 237 237 } 238 238 if err := runHook(prof.PostHook, prof, postEnv...); err != nil { 239 - setProfileFailed(idx, "Post-hook failed") 239 + setProfileFailed(key, "Post-hook failed") 240 240 notifyError(label, "Post-hook failed") 241 241 } 242 242 } 243 243 } 244 244 245 - func runBackup(idx int, mStatus prefixedMenuItem, prof Profile, onDone func()) { 245 + func runBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 246 246 if p, _ := findRestic(); p == "" { 247 247 return 248 248 } 249 - if !acquireProfile(idx) { 249 + if !acquireProfile(key) { 250 250 return 251 251 } 252 - setProfileFailed(idx, "") 252 + setProfileFailed(key, "") 253 253 defer onDone() 254 - defer setProfileBusy(idx, false) 254 + defer releaseProfile(key) 255 255 256 256 mStatus.SetTitle("Backing up...") 257 - ok, code := doBackup(idx, mStatus, prof, false) 257 + ok, code := doBackup(key, mStatus, prof, false) 258 258 if ok { 259 259 if code == 3 { 260 260 notifyError("Backup", "Completed with warnings: some files could not be read") ··· 262 262 notifySuccess("Backup") 263 263 } 264 264 } else { 265 - notifyError("Backup", getProfileFailStatus(idx)) 265 + notifyError("Backup", getProfileFailStatus(key)) 266 266 } 267 267 } 268 268 ··· 307 307 return err 308 308 } 309 309 310 - func runScheduled(idx int, mStatus prefixedMenuItem, prof Profile, onDone func()) { 310 + func runScheduled(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 311 311 if p, _ := findRestic(); p == "" { 312 312 return 313 313 } ··· 323 323 ops = append(ops, "check") 324 324 } 325 325 326 - runWithHooks(idx, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { 326 + runWithHooks(key, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { 327 327 if prof.Schedule.BackupEnabled() { 328 328 mStatus.SetTitle("Backing up...") 329 - if ok, _ := doBackup(idx, mStatus, prof, true); !ok { 329 + if ok, _ := doBackup(key, mStatus, prof, true); !ok { 330 330 return false, "" 331 331 } 332 332 } 333 333 failed := false 334 334 if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 335 335 mStatus.SetTitle("Pruning repository...") 336 - if msg, err := runRestic(idx, prof, mStatus, forgetArgs(prof)...); err != nil { 337 - setProfileFailed(idx, msg) 336 + if msg, err := runRestic(key, prof, mStatus, forgetArgs(prof)...); err != nil { 337 + setProfileFailed(key, msg) 338 338 failed = true 339 339 } 340 340 } 341 341 if prof.Schedule.Check { 342 342 mStatus.SetTitle("Checking repository...") 343 - if msg, err := runRestic(idx, prof, mStatus, checkArgs(prof)...); err != nil { 344 - setProfileFailed(idx, msg) 343 + if msg, err := runRestic(key, prof, mStatus, checkArgs(prof)...); err != nil { 344 + setProfileFailed(key, msg) 345 345 failed = true 346 346 } 347 347 } ··· 370 370 return append([]string{"check"}, prof.Check.Args...) 371 371 } 372 372 373 - func isProfileMounted(idx int) bool { 373 + func isProfileMounted(key profileKey) bool { 374 374 state.mu.Lock() 375 375 defer state.mu.Unlock() 376 - return state.mountCmds[idx] != nil 376 + return state.mountCmds[key] != nil 377 377 } 378 378 379 - func startMount(idx int, prof Profile, mMount *systray.MenuItem, onDone func()) { 379 + func startMount(key profileKey, prof Profile, mMount *systray.MenuItem, onDone func()) { 380 380 dir, err := os.MkdirTemp("", "restray-mount-") 381 381 if err != nil { 382 382 return ··· 396 396 } 397 397 398 398 state.mu.Lock() 399 - state.mountCmds[idx] = cmd 399 + state.mountCmds[key] = cmd 400 400 state.mu.Unlock() 401 401 402 402 mMount.SetTitle("Unmount") ··· 424 424 cleanup: 425 425 426 426 state.mu.Lock() 427 - delete(state.mountCmds, idx) 428 - stopped := state.mountStopping[idx] 429 - delete(state.mountStopping, idx) 427 + delete(state.mountCmds, key) 428 + stopped := state.mountStopping[key] 429 + delete(state.mountStopping, key) 430 430 state.mu.Unlock() 431 431 os.Remove(dir) 432 432 ··· 437 437 onDone() 438 438 } 439 439 440 - func stopProfileMount(idx int) { 440 + func stopProfileMount(key profileKey) { 441 441 state.mu.Lock() 442 442 defer state.mu.Unlock() 443 - if cmd := state.mountCmds[idx]; cmd != nil && cmd.Process != nil { 444 - state.mountStopping[idx] = true 443 + if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil { 444 + state.mountStopping[key] = true 445 445 interruptProcess(cmd.Process) 446 446 } 447 447 } ··· 449 449 func stopAllMounts() { 450 450 state.mu.Lock() 451 451 defer state.mu.Unlock() 452 - for idx, cmd := range state.mountCmds { 452 + for key, cmd := range state.mountCmds { 453 453 if cmd != nil && cmd.Process != nil { 454 - state.mountStopping[idx] = true 454 + state.mountStopping[key] = true 455 455 interruptProcess(cmd.Process) 456 456 } 457 457 }
+50 -57
cmd/restray/restic.go
··· 127 127 needsInit bool 128 128 } 129 129 130 - func repoStatus(prof Profile) repoResult { 130 + func probeRepo(prof Profile, args []string) ([]byte, repoResult) { 131 131 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 132 132 defer cancel() 133 133 path, _ := findRestic() 134 - args := []string{"cat", "config", "--no-lock", "-q"} 135 134 log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 136 135 start := time.Now() 137 136 cmd := exec.CommandContext(ctx, path, args...) 138 137 cmd.Env = resticEnv(prof) 139 138 hideWindow(cmd) 140 - out, err := cmd.CombinedOutput() 139 + var stderr strings.Builder 140 + cmd.Stderr = &stderr 141 + out, err := cmd.Output() 141 142 if err != nil { 142 - trimmed := strings.TrimSpace(string(out)) 143 + trimmed := strings.TrimSpace(stderr.String()) 143 144 if ctx.Err() != nil { 144 145 log.Printf("[%s] repo status timed out after 30s; last output: %s", prof.displayName(), trimmed) 145 - return repoResult{errMsg: "Repository unreachable"} 146 + return nil, repoResult{errMsg: "Repository unreachable"} 146 147 } 147 148 log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) 148 149 lines := strings.Split(trimmed, "\n") 149 150 msg, code := classifyError(err, lines[len(lines)-1]) 150 - return repoResult{errMsg: msg, needsInit: code == 10} 151 + return nil, repoResult{errMsg: msg, needsInit: code == 10} 151 152 } 152 153 log.Printf("[%s] repo status ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 153 - return repoResult{} 154 + return out, repoResult{} 155 + } 156 + 157 + func repoStatus(prof Profile) repoResult { 158 + _, rr := probeRepo(prof, []string{"cat", "config", "--no-lock", "-q"}) 159 + return rr 154 160 } 155 161 156 - func queryLastBackup(prof Profile) time.Time { 162 + func repoStatusAndLastBackup(prof Profile) (repoResult, time.Time) { 157 163 host, err := os.Hostname() 158 164 if err != nil { 159 - return time.Time{} 165 + return repoStatus(prof), time.Time{} 160 166 } 161 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 162 - defer cancel() 163 - path, _ := findRestic() 164 - args := []string{"snapshots", "--json", "--latest", "1", "--host", host} 165 - log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 166 - start := time.Now() 167 - cmd := exec.CommandContext(ctx, path, args...) 168 - cmd.Env = resticEnv(prof) 169 - hideWindow(cmd) 170 - out, err := cmd.Output() 171 - if err != nil { 172 - if ctx.Err() != nil { 173 - log.Printf("[%s] last backup query timed out after 30s", prof.displayName()) 174 - } else { 175 - log.Printf("[%s] last backup query error: %v", prof.displayName(), err) 176 - } 177 - return time.Time{} 167 + out, rr := probeRepo(prof, []string{"snapshots", "--json", "--latest", "1", "--host", host}) 168 + if rr.errMsg != "" { 169 + return rr, time.Time{} 178 170 } 179 - log.Printf("[%s] last backup query ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 180 171 var snaps []struct { 181 172 Time time.Time `json:"time"` 182 173 } 183 174 if json.Unmarshal(out, &snaps) != nil || len(snaps) == 0 { 184 - return time.Time{} 175 + return rr, time.Time{} 185 176 } 186 - return snaps[0].Time 177 + return rr, snaps[0].Time 187 178 } 179 + 180 + type profileKey = string 188 181 189 182 type appState struct { 190 183 mu sync.Mutex 191 - busyProfiles map[int]*exec.Cmd 184 + busyProfiles map[profileKey]*exec.Cmd 192 185 onBusyChanged func(bool) 193 - mountCmds map[int]*exec.Cmd 194 - mountStopping map[int]bool 186 + mountCmds map[profileKey]*exec.Cmd 187 + mountStopping map[profileKey]bool 195 188 notifications string 196 - failStatus map[int]string 197 - lastBackup map[int]time.Time 189 + failStatus map[profileKey]string 190 + lastBackup map[profileKey]time.Time 198 191 } 199 192 200 193 var state = appState{ 201 - busyProfiles: make(map[int]*exec.Cmd), 202 - mountCmds: make(map[int]*exec.Cmd), 203 - mountStopping: make(map[int]bool), 204 - failStatus: make(map[int]string), 205 - lastBackup: make(map[int]time.Time), 194 + busyProfiles: make(map[profileKey]*exec.Cmd), 195 + mountCmds: make(map[profileKey]*exec.Cmd), 196 + mountStopping: make(map[profileKey]bool), 197 + failStatus: make(map[profileKey]string), 198 + lastBackup: make(map[profileKey]time.Time), 206 199 } 207 200 208 - func setLastBackup(idx int, t time.Time) { 201 + func setLastBackup(key profileKey, t time.Time) { 209 202 state.mu.Lock() 210 - state.lastBackup[idx] = t 203 + state.lastBackup[key] = t 211 204 state.mu.Unlock() 212 205 } 213 206 214 - func getLastBackup(idx int) time.Time { 207 + func getLastBackup(key profileKey) time.Time { 215 208 state.mu.Lock() 216 209 defer state.mu.Unlock() 217 - return state.lastBackup[idx] 210 + return state.lastBackup[key] 218 211 } 219 212 220 - func isProfileBusy(idx int) bool { 213 + func isProfileBusy(key profileKey) bool { 221 214 state.mu.Lock() 222 215 defer state.mu.Unlock() 223 - _, ok := state.busyProfiles[idx] 216 + _, ok := state.busyProfiles[key] 224 217 return ok 225 218 } 226 219 ··· 230 223 return len(state.busyProfiles) > 0 231 224 } 232 225 233 - func setProfileBusy(idx int, active bool) { 226 + func releaseProfile(key profileKey) { 234 227 state.mu.Lock() 235 - applyBusy(idx, active) 228 + applyBusy(key, false) 236 229 } 237 230 238 - func acquireProfile(idx int) bool { 231 + func acquireProfile(key profileKey) bool { 239 232 state.mu.Lock() 240 - if _, busy := state.busyProfiles[idx]; busy { 233 + if _, busy := state.busyProfiles[key]; busy { 241 234 state.mu.Unlock() 242 235 return false 243 236 } 244 - applyBusy(idx, true) 237 + applyBusy(key, true) 245 238 return true 246 239 } 247 240 248 - func applyBusy(idx int, active bool) { 241 + func applyBusy(key profileKey, active bool) { 249 242 wasBusy := len(state.busyProfiles) > 0 250 243 if active { 251 - state.busyProfiles[idx] = nil 244 + state.busyProfiles[key] = nil 252 245 } else { 253 - delete(state.busyProfiles, idx) 246 + delete(state.busyProfiles, key) 254 247 } 255 248 nowBusy := len(state.busyProfiles) > 0 256 249 if nowBusy && !wasBusy { ··· 263 256 } 264 257 } 265 258 266 - func setProfileBusyCmd(idx int, cmd *exec.Cmd) { 259 + func setProfileBusyCmd(key profileKey, cmd *exec.Cmd) { 267 260 state.mu.Lock() 268 261 defer state.mu.Unlock() 269 - state.busyProfiles[idx] = cmd 262 + state.busyProfiles[key] = cmd 270 263 } 271 264 272 - func cancelProfile(idx int) { 265 + func cancelProfile(key profileKey) { 273 266 state.mu.Lock() 274 267 defer state.mu.Unlock() 275 - if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 268 + if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { 276 269 interruptProcess(cmd.Process) 277 270 } 278 271 }
+63 -49
cmd/restray/tray.go
··· 66 66 return activeProfile 67 67 } 68 68 69 - func setProfileFailed(idx int, status string) { 69 + func setProfileFailed(key profileKey, status string) { 70 70 state.mu.Lock() 71 71 if status == "" { 72 - delete(state.failStatus, idx) 72 + delete(state.failStatus, key) 73 73 } else { 74 - state.failStatus[idx] = status 74 + state.failStatus[key] = status 75 75 } 76 76 state.mu.Unlock() 77 77 } 78 78 79 - func getProfileFailStatus(idx int) string { 79 + func getProfileFailStatus(key profileKey) string { 80 80 state.mu.Lock() 81 81 defer state.mu.Unlock() 82 - return state.failStatus[idx] 82 + return state.failStatus[key] 83 83 } 84 84 85 85 func anyError() bool { ··· 99 99 return false 100 100 } 101 101 102 + func profilePrefix(cfg Config, prof Profile) string { 103 + if len(cfg.Profiles) > 1 { 104 + return prof.displayName() + " - " 105 + } 106 + return "" 107 + } 108 + 102 109 func unreachableRecovered(cfg Config) bool { 103 110 profileMu.Lock() 104 - var unreachable []int 111 + unreachable := make(map[string]bool) 105 112 for i, p := range profileStates { 106 - if p.errMsg == "" && p.repoErr != "" { 107 - unreachable = append(unreachable, i) 113 + if p.errMsg == "" && p.repoErr != "" && i < len(cfg.Profiles) { 114 + unreachable[cfg.Profiles[i].displayName()] = true 108 115 } 109 116 } 110 117 profileMu.Unlock() 111 118 112 - for _, i := range unreachable { 113 - if i < len(cfg.Profiles) && repoStatus(cfg.Profiles[i]).errMsg == "" { 119 + for _, prof := range cfg.Profiles { 120 + if unreachable[prof.displayName()] && repoStatus(prof).errMsg == "" { 114 121 return true 115 122 } 116 123 } ··· 266 273 statusItem := func(idx int) prefixedMenuItem { 267 274 cfg := loadConfig() 268 275 prefix := "" 269 - if len(cfg.Profiles) > 1 && idx < len(cfg.Profiles) { 270 - prefix = cfg.Profiles[idx].displayName() + " - " 276 + if idx < len(cfg.Profiles) { 277 + prefix = profilePrefix(cfg, cfg.Profiles[idx]) 271 278 } 272 279 return prefixedMenuItem{mStatusItems[idx], prefix} 273 280 } ··· 282 289 eids := profileEntryIDs[i] 283 290 profileMu.Unlock() 284 291 prof := cfg.Profiles[i] 285 - 286 - prefix := "" 287 - if len(cfg.Profiles) > 1 { 288 - prefix = prof.displayName() + " - " 289 - } 292 + prefix := profilePrefix(cfg, prof) 290 293 291 294 switch { 292 295 case ps.errMsg != "": ··· 315 318 } 316 319 return prefix + prof.Schedule.Cron 317 320 case "last": 318 - last := formatLastBackup(getLastBackup(i)) 321 + last := formatLastBackup(getLastBackup(prof.displayName())) 319 322 if next != "" { 320 323 return prefix + next + ", last " + last 321 324 } ··· 333 336 updateAllStatusItems := func(cfg Config) { 334 337 mGlobalStatus.Hide() 335 338 for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { 336 - if !isProfileBusy(i) { 337 - failMsg := getProfileFailStatus(i) 339 + key := cfg.Profiles[i].displayName() 340 + if !isProfileBusy(key) { 341 + failMsg := getProfileFailStatus(key) 338 342 if failMsg != "" { 339 - if len(cfg.Profiles) > 1 { 340 - failMsg = cfg.Profiles[i].displayName() + " - " + failMsg 341 - } 343 + failMsg = profilePrefix(cfg, cfg.Profiles[i]) + failMsg 342 344 if len(failMsg) > maxMenuWidth { 343 345 failMsg = failMsg[:maxMenuWidth-1] + "…" 344 346 } ··· 364 366 ps := profileStates[idx] 365 367 profileMu.Unlock() 366 368 prof := cfg.Profiles[idx] 369 + key := prof.displayName() 367 370 368 371 createOrEdit(mEnv, "Env File", prof.EnvFile) 369 372 if envFilesInsecure(cfg.Profiles) { ··· 380 383 381 384 if runtime.GOOS == "windows" { 382 385 mMount.Hide() 383 - } else if isProfileMounted(idx) { 386 + } else if isProfileMounted(key) { 384 387 mMount.SetTitle("Unmount") 385 388 } else { 386 389 mMount.SetTitle("Mount") 387 390 } 388 391 389 - if isProfileBusy(idx) { 392 + if isProfileBusy(key) { 390 393 mCancel.Show() 391 394 mRepo.Enable() 392 395 mBackup.Disable() ··· 509 512 var ps profileState 510 513 ps.errMsg = prof.configError() 511 514 if ps.errMsg == "" { 512 - rs := repoStatus(prof) 513 - ps.repoErr = rs.errMsg 514 - ps.needsInit = rs.needsInit 515 - if rs.errMsg == "" && !rs.needsInit && cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() { 516 - if last := queryLastBackup(prof); !last.IsZero() { 517 - setLastBackup(i, last) 515 + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.displayName()).IsZero() 516 + var rs repoResult 517 + if wantsLast { 518 + var last time.Time 519 + rs, last = repoStatusAndLastBackup(prof) 520 + if !last.IsZero() { 521 + setLastBackup(prof.displayName(), last) 518 522 } 523 + } else { 524 + rs = repoStatus(prof) 519 525 } 526 + ps.repoErr = rs.errMsg 527 + ps.needsInit = rs.needsInit 520 528 } 521 529 522 530 if ps.errMsg == "" && prof.Schedule.Cron != "" { 523 531 name := prof.displayName() 524 532 eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 525 533 current := loadConfig() 526 - _, prof, err := resolveProfile(current, name) 534 + prof, err := resolveProfile(current, name) 527 535 if err != nil { 528 536 log.Printf("[%s] skipping scheduled backup: %v", name, err) 529 537 return ··· 538 546 log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 539 547 } 540 548 }() 541 - runScheduled(i, statusItem(i), prof, applyConfig) 549 + runScheduled(prof.displayName(), statusItem(i), prof, applyConfig) 542 550 }() 543 551 }) 544 552 if err != nil { ··· 645 653 646 654 repoOp := func(status string, args ...string) { 647 655 idx := getActiveProfile() 648 - if idx >= maxProfiles || isProfileBusy(idx) { 656 + prof := selectedProfile() 657 + key := prof.displayName() 658 + if idx >= maxProfiles || isProfileBusy(key) { 649 659 return 650 660 } 651 - prof := selectedProfile() 652 661 ms := statusItem(idx) 653 662 go func() { 654 - if !acquireProfile(idx) { 663 + if !acquireProfile(key) { 655 664 return 656 665 } 657 - setProfileFailed(idx, "") 666 + setProfileFailed(key, "") 658 667 defer applyConfig() 659 - defer setProfileBusy(idx, false) 668 + defer releaseProfile(key) 660 669 ms.SetTitle(status) 661 - if msg, err := runRestic(idx, prof, ms, args...); err != nil { 662 - setProfileFailed(idx, msg) 670 + if msg, err := runRestic(key, prof, ms, args...); err != nil { 671 + setProfileFailed(key, msg) 663 672 notifyError(args[0], msg) 664 673 } else { 665 674 notifySuccess(args[0]) ··· 672 681 select { 673 682 case <-mSchedule.ClickedCh: 674 683 idx := getActiveProfile() 675 - if idx < maxProfiles && !isProfileBusy(idx) { 676 - go runScheduled(idx, statusItem(idx), selectedProfile(), applyConfig) 684 + prof := selectedProfile() 685 + key := prof.displayName() 686 + if idx < maxProfiles && !isProfileBusy(key) { 687 + go runScheduled(key, statusItem(idx), prof, applyConfig) 677 688 } 678 689 case <-mBackup.ClickedCh: 679 690 idx := getActiveProfile() 680 - if idx < maxProfiles && !isProfileBusy(idx) { 681 - go runBackup(idx, statusItem(idx), selectedProfile(), applyConfig) 691 + prof := selectedProfile() 692 + key := prof.displayName() 693 + if idx < maxProfiles && !isProfileBusy(key) { 694 + go runBackup(key, statusItem(idx), prof, applyConfig) 682 695 } 683 696 case <-mCancel.ClickedCh: 684 - cancelProfile(getActiveProfile()) 697 + cancelProfile(selectedProfile().displayName()) 685 698 case <-mPrune.ClickedCh: 686 699 prof := selectedProfile() 687 700 if len(prof.Prune.Args) > 0 { ··· 694 707 case <-mInit.ClickedCh: 695 708 repoOp("Initializing repository...", "init") 696 709 case <-mMount.ClickedCh: 697 - idx := getActiveProfile() 698 - if isProfileMounted(idx) { 699 - stopProfileMount(idx) 710 + prof := selectedProfile() 711 + key := prof.displayName() 712 + if isProfileMounted(key) { 713 + stopProfileMount(key) 700 714 } else { 701 - go startMount(idx, selectedProfile(), mMount, applyConfig) 715 + go startMount(key, prof, mMount, applyConfig) 702 716 } 703 717 case <-mConsole.ClickedCh: 704 718 cfg := loadConfig()