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: 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

intergrav (Jul 2, 2026, 10:34 PM EDT) 1df16f2d 5291840c

+350 -281
+37 -33
cmd/restray/cli.go
··· 10 10 "os/signal" 11 11 "runtime" 12 12 "strings" 13 - "sync" 14 13 "sync/atomic" 15 14 "syscall" 16 15 16 + "github.com/google/shlex" 17 17 "github.com/robfig/cron/v3" 18 18 "github.com/urfave/cli/v3" 19 19 ) ··· 22 22 if name == "" { 23 23 return cfg.Profiles[0], nil 24 24 } 25 + var found Profile 26 + matches := 0 25 27 for _, p := range cfg.Profiles { 26 28 if p.displayName() == name { 27 - return p, nil 29 + found = p 30 + matches++ 28 31 } 29 32 } 33 + if matches == 1 { 34 + return found, nil 35 + } 36 + if matches > 1 { 37 + return Profile{}, fmt.Errorf("profile %q is ambiguous (matched %d profiles)", name, matches) 38 + } 30 39 var names []string 31 40 for _, p := range cfg.Profiles { 32 41 names = append(names, p.displayName()) ··· 34 43 return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) 35 44 } 36 45 46 + func resolveProfileAt(cfg Config, idx int, name string) (Profile, error) { 47 + if idx >= 0 && idx < len(cfg.Profiles) && cfg.Profiles[idx].displayName() == name { 48 + return cfg.Profiles[idx], nil 49 + } 50 + return resolveProfile(cfg, name) 51 + } 52 + 37 53 func editorCmd() string { 38 54 if e := os.Getenv("VISUAL"); e != "" { 39 55 return e ··· 63 79 } 64 80 65 81 func cliEdit(path string) error { 66 - cmd := exec.Command(editorCmd(), path) 82 + editor := editorCmd() 83 + cmd := exec.Command(editor, path) 84 + if runtime.GOOS != "windows" || !fileExists(editor) { 85 + if parts, err := shlex.Split(editor); err == nil && len(parts) > 0 { 86 + cmd = exec.Command(parts[0], append(parts[1:], path)...) 87 + } 88 + } 67 89 cmd.Stdin = os.Stdin 68 90 cmd.Stdout = os.Stdout 69 91 cmd.Stderr = os.Stderr ··· 181 203 interrupted.Store(true) 182 204 }() 183 205 184 - var ops []string 185 - if prof.Schedule.BackupEnabled() { 186 - ops = append(ops, "backup") 187 - } 188 - if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 189 - ops = append(ops, "prune") 190 - } 191 - if prof.Schedule.Check { 192 - ops = append(ops, "check") 193 - } 194 - 195 206 hookEnv := []string{ 196 - "RESTRAY_OPERATIONS=" + strings.Join(ops, ","), 207 + "RESTRAY_OPERATIONS=" + strings.Join(scheduledOps(prof), ","), 197 208 "RESTRAY_SCHEDULED=true", 198 209 } 199 210 ··· 249 260 return nil 250 261 } 251 262 252 - func buildDaemonSchedule(wg *sync.WaitGroup) (*cron.Cron, error) { 263 + func buildDaemonSchedule() (*cron.Cron, error) { 253 264 sched := cron.New() 254 - for _, prof := range loadConfig().Profiles { 265 + for idx, prof := range loadConfig().Profiles { 255 266 if prof.Schedule.Cron == "" { 256 267 continue 257 268 } 258 269 name := prof.displayName() 259 270 _, err := sched.AddFunc(prof.Schedule.Cron, func() { 260 271 current := loadConfig() 261 - prof, err := resolveProfile(current, name) 272 + prof, err := resolveProfileAt(current, idx, name) 262 273 if err != nil { 263 274 log.Printf("[%s] skipping scheduled run: %v", name, err) 264 275 return ··· 267 278 log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) 268 279 return 269 280 } 270 - wg.Add(1) 271 - go func() { 272 - defer wg.Done() 273 - defer func() { 274 - if r := recover(); r != nil { 275 - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 276 - } 277 - }() 278 - log.Printf("[%s] running scheduled job", prof.displayName()) 279 - cliSchedule(prof) 281 + defer func() { 282 + if r := recover(); r != nil { 283 + log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 284 + } 280 285 }() 286 + log.Printf("[%s] running scheduled job", prof.displayName()) 287 + cliSchedule(prof) 281 288 }) 282 289 if err != nil { 283 290 return nil, fmt.Errorf("invalid cron expression %q for profile %q: %w", prof.Schedule.Cron, name, err) ··· 295 302 return cli.Exit("error: restic not found", 1) 296 303 } 297 304 298 - var wg sync.WaitGroup 299 - sched, err := buildDaemonSchedule(&wg) 305 + sched, err := buildDaemonSchedule() 300 306 if err != nil { 301 307 return cli.Exit(fmt.Sprintf("error: %v", err), 1) 302 308 } ··· 310 316 if sig != syscall.SIGHUP { 311 317 break 312 318 } 313 - next, err := buildDaemonSchedule(&wg) 319 + next, err := buildDaemonSchedule() 314 320 if err != nil { 315 321 log.Printf("error: reload failed, keeping previous schedule: %v", err) 316 322 continue ··· 322 328 } 323 329 324 330 <-sched.Stop().Done() 325 - log.Print("Waiting for running jobs to finish...") 326 - wg.Wait() 327 331 return nil 328 332 } 329 333
+3
cmd/restray/config.go
··· 111 111 } 112 112 env[key] = val 113 113 } 114 + if err := scanner.Err(); err != nil { 115 + return nil, err 116 + } 114 117 return env, nil 115 118 } 116 119
+2 -1
cmd/restray/manage.go
··· 40 40 } 41 41 42 42 func resetResticPath() { 43 - resticOnce = sync.Once{} 43 + resticMu.Lock() 44 + defer resticMu.Unlock() 44 45 resticPath = "" 45 46 resticManaged = false 46 47 }
+119 -116
cmd/restray/operations.go
··· 48 48 return sl 49 49 } 50 50 51 - func runResticOnce(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 51 + func runResticOnce(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 52 52 cmd := resticCmd(prof, args...) 53 53 var stdout strings.Builder 54 54 cmd.Stdout = &stdout 55 55 stderrPipe, _ := cmd.StderrPipe() 56 - setProfileBusyCmd(key, cmd) 56 + setProfileBusyCmd(idx, cmd) 57 57 58 58 if err := cmd.Start(); err != nil { 59 59 log.Printf("[%s] start failed: %v", prof.displayName(), err) ··· 81 81 return prof.RetryLock != "" && prof.RetryLock != "0" 82 82 } 83 83 84 - func retryUnlock(key profileKey, prof Profile, mStatus prefixedMenuItem) { 84 + func retryUnlock(idx profileIndex, prof Profile, mStatus prefixedMenuItem) { 85 85 log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 86 86 mStatus.SetTitle("Unlocking repository...") 87 - runResticOnce(key, prof, mStatus, "unlock") 87 + runResticOnce(idx, prof, mStatus, "unlock") 88 88 } 89 89 90 - func runRestic(key profileKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 90 + func runRestic(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 91 91 if retryLockEnabled(prof) { 92 92 args = append(args, "--retry-lock", prof.RetryLock) 93 93 } 94 - msg, err := runResticOnce(key, prof, mStatus, args...) 94 + msg, err := runResticOnce(idx, prof, mStatus, args...) 95 95 if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 96 - retryUnlock(key, prof, mStatus) 97 - msg, err = runResticOnce(key, prof, mStatus, args...) 96 + retryUnlock(idx, prof, mStatus) 97 + msg, err = runResticOnce(idx, prof, mStatus, args...) 98 98 } 99 99 return msg, err 100 100 } 101 101 102 - func doBackupOnce(key profileKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 102 + func doBackupOnce(idx profileIndex, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 103 103 cmd := resticCmd(prof, args...) 104 104 stdoutPipe, _ := cmd.StdoutPipe() 105 105 stderrPipe, _ := cmd.StderrPipe() 106 - setProfileBusyCmd(key, cmd) 106 + setProfileBusyCmd(idx, cmd) 107 107 108 108 if err := cmd.Start(); err != nil { 109 109 log.Printf("[%s] start failed: %v", prof.displayName(), err) 110 - setProfileFailed(key, err.Error()) 110 + setProfileFailed(idx, err.Error()) 111 111 return false, -1 112 112 } 113 113 ··· 152 152 log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) 153 153 return true, 3 154 154 } 155 - setProfileFailed(key, msg) 155 + setProfileFailed(idx, msg) 156 156 return false, code 157 157 } 158 158 log.Printf("[%s] done: backup", prof.displayName()) 159 159 return true, 0 160 160 } 161 161 162 - func doBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 162 + func doBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 163 163 args := backupArgs(prof, scheduled, "--json") 164 164 165 - ok, code := doBackupOnce(key, mStatus, prof, args) 165 + ok, code := doBackupOnce(idx, mStatus, prof, args) 166 166 if !ok && code == 11 && retryLockEnabled(prof) { 167 - retryUnlock(key, prof, mStatus) 167 + retryUnlock(idx, prof, mStatus) 168 168 mStatus.SetTitle("Backing up...") 169 - setProfileFailed(key, "") 170 - ok, code = doBackupOnce(key, mStatus, prof, args) 169 + setProfileFailed(idx, "") 170 + ok, code = doBackupOnce(idx, mStatus, prof, args) 171 171 } 172 172 if ok { 173 - setLastBackup(key, time.Now()) 173 + setLastBackup(idx, time.Now()) 174 174 } 175 175 return ok, code 176 176 } 177 177 178 - func runWithHooks(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func(), 179 - label, opsEnv string, op func() (bool, string)) { 180 - if !acquireProfile(key) { 181 - return 182 - } 183 - setProfileFailed(key, "") 184 - defer onDone() 185 - defer releaseProfile(key) 186 - 187 - hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=true"} 188 - 189 - if prof.PreHook != "" { 190 - mStatus.SetTitle("Running pre-hook...") 191 - if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 192 - setProfileFailed(key, "Pre-hook failed") 193 - notifyError(label, "Pre-hook failed") 194 - return 195 - } 196 - } 197 - 198 - ok, successMsg := op() 199 - 200 - if ok { 201 - if successMsg != "" { 202 - notifyError(label, successMsg) 203 - } else { 204 - notifySuccess(label) 205 - } 206 - } else { 207 - notifyError(label, getProfileFailStatus(key)) 208 - } 209 - 210 - if prof.PostHook != "" { 211 - mStatus.SetTitle("Running post-hook...") 212 - postEnv := hookEnv 213 - if !ok { 214 - postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key)) 215 - } 216 - if err := runHook(prof.PostHook, prof, postEnv...); err != nil { 217 - setProfileFailed(key, "Post-hook failed") 218 - notifyError(label, "Post-hook failed") 219 - } 220 - } 221 - } 222 - 223 - func runBackup(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 178 + func runBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 224 179 if p, _ := findRestic(); p == "" { 225 180 return 226 181 } 227 - if !acquireProfile(key) { 182 + if !acquireProfile(idx) { 228 183 return 229 184 } 230 - setProfileFailed(key, "") 185 + setProfileFailed(idx, "") 231 186 defer onDone() 232 - defer releaseProfile(key) 187 + defer releaseProfile(idx) 233 188 234 189 mStatus.SetTitle("Backing up...") 235 - ok, code := doBackup(key, mStatus, prof, false) 190 + ok, code := doBackup(idx, mStatus, prof, false) 236 191 if ok { 237 192 if code == 3 { 238 193 notifyError("Backup", "Completed with warnings: some files could not be read") ··· 240 195 notifySuccess("Backup") 241 196 } 242 197 } else { 243 - notifyError("Backup", getProfileFailStatus(key)) 198 + notifyError("Backup", getProfileFailStatus(idx)) 244 199 } 245 200 } 246 201 ··· 285 240 return err 286 241 } 287 242 288 - func runScheduled(key profileKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 289 - if p, _ := findRestic(); p == "" { 290 - return 291 - } 292 - 243 + func scheduledOps(prof Profile) []string { 293 244 var ops []string 294 245 if prof.Schedule.BackupEnabled() { 295 246 ops = append(ops, "backup") ··· 300 251 if prof.Schedule.Check { 301 252 ops = append(ops, "check") 302 253 } 254 + return ops 255 + } 303 256 304 - runWithHooks(key, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { 305 - if prof.Schedule.BackupEnabled() { 306 - mStatus.SetTitle("Backing up...") 307 - if ok, _ := doBackup(key, mStatus, prof, true); !ok { 308 - return false, "" 309 - } 257 + func runScheduled(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 258 + if p, _ := findRestic(); p == "" { 259 + return 260 + } 261 + if !acquireProfile(idx) { 262 + return 263 + } 264 + setProfileFailed(idx, "") 265 + defer onDone() 266 + defer releaseProfile(idx) 267 + 268 + hookEnv := []string{ 269 + "RESTRAY_OPERATIONS=" + strings.Join(scheduledOps(prof), ","), 270 + "RESTRAY_SCHEDULED=true", 271 + } 272 + 273 + if prof.PreHook != "" { 274 + mStatus.SetTitle("Running pre-hook...") 275 + if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 276 + setProfileFailed(idx, "Pre-hook failed") 277 + notifyError("Schedule", "Pre-hook failed") 278 + return 279 + } 280 + } 281 + 282 + failed := false 283 + skipRemaining := false 284 + if prof.Schedule.BackupEnabled() { 285 + mStatus.SetTitle("Backing up...") 286 + if ok, _ := doBackup(idx, mStatus, prof, true); !ok { 287 + failed = true 288 + skipRemaining = true 289 + } 290 + } 291 + if !skipRemaining && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 292 + mStatus.SetTitle("Pruning repository...") 293 + if msg, err := runRestic(idx, prof, mStatus, forgetArgs(prof)...); err != nil { 294 + setProfileFailed(idx, msg) 295 + failed = true 296 + } 297 + } 298 + if !skipRemaining && prof.Schedule.Check { 299 + mStatus.SetTitle("Checking repository...") 300 + if msg, err := runRestic(idx, prof, mStatus, checkArgs(prof)...); err != nil { 301 + setProfileFailed(idx, msg) 302 + failed = true 310 303 } 311 - failed := false 312 - if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 313 - mStatus.SetTitle("Pruning repository...") 314 - if msg, err := runRestic(key, prof, mStatus, forgetArgs(prof)...); err != nil { 315 - setProfileFailed(key, msg) 316 - failed = true 317 - } 304 + } 305 + 306 + if failed { 307 + notifyError("Schedule", getProfileFailStatus(idx)) 308 + } else { 309 + notifySuccess("Schedule") 310 + } 311 + 312 + if prof.PostHook != "" { 313 + mStatus.SetTitle("Running post-hook...") 314 + postEnv := hookEnv 315 + if failed { 316 + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) 318 317 } 319 - if prof.Schedule.Check { 320 - mStatus.SetTitle("Checking repository...") 321 - if msg, err := runRestic(key, prof, mStatus, checkArgs(prof)...); err != nil { 322 - setProfileFailed(key, msg) 323 - failed = true 324 - } 318 + if err := runHook(prof.PostHook, prof, postEnv...); err != nil { 319 + setProfileFailed(idx, "Post-hook failed") 320 + notifyError("Schedule", "Post-hook failed") 325 321 } 326 - return !failed, "" 327 - }) 322 + } 328 323 } 329 324 330 325 func backupArgs(prof Profile, scheduled bool, extra ...string) []string { ··· 348 343 return append([]string{"check"}, prof.Check.Args...) 349 344 } 350 345 351 - func isProfileMounted(key profileKey) bool { 346 + func isProfileMounted(idx profileIndex) bool { 352 347 state.mu.Lock() 353 348 defer state.mu.Unlock() 354 - return state.mountCmds[key] != nil 349 + return state.mountCmds[idx] != nil 355 350 } 356 351 357 - func startMount(key profileKey, prof Profile, mMount *systray.MenuItem, onDone func()) { 352 + func startMount(idx profileIndex, prof Profile, mMount *systray.MenuItem, onDone func()) { 358 353 dir, err := os.MkdirTemp("", "restray-mount-") 359 354 if err != nil { 360 355 return ··· 374 369 } 375 370 376 371 state.mu.Lock() 377 - state.mountCmds[key] = cmd 372 + state.mountCmds[idx] = cmd 378 373 state.mu.Unlock() 379 374 380 375 mMount.SetTitle("Unmount") ··· 383 378 go func() { done <- cmd.Wait() }() 384 379 385 380 mounted := false 386 - for i := 0; i < 20; i++ { 381 + ticker := time.NewTicker(250 * time.Millisecond) 382 + defer ticker.Stop() 383 + timeout := time.NewTimer(30 * time.Second) 384 + defer timeout.Stop() 385 + wait: 386 + for { 387 387 select { 388 388 case <-done: 389 - goto cleanup 390 - case <-time.After(250 * time.Millisecond): 391 - } 392 - entries, _ := os.ReadDir(dir) 393 - if len(entries) > 0 { 394 - mounted = true 395 - break 389 + break wait 390 + case <-timeout.C: 391 + interruptProcess(cmd.Process) 392 + <-done 393 + break wait 394 + case <-ticker.C: 395 + entries, _ := os.ReadDir(dir) 396 + if len(entries) > 0 { 397 + mounted = true 398 + break wait 399 + } 396 400 } 397 401 } 398 402 if mounted { 399 403 openFile(dir) 400 404 <-done 401 405 } 402 - cleanup: 403 406 404 407 state.mu.Lock() 405 - delete(state.mountCmds, key) 406 - stopped := state.mountStopping[key] 407 - delete(state.mountStopping, key) 408 + delete(state.mountCmds, idx) 409 + stopped := state.mountStopping[idx] 410 + delete(state.mountStopping, idx) 408 411 state.mu.Unlock() 409 412 os.Remove(dir) 410 413 ··· 415 418 onDone() 416 419 } 417 420 418 - func stopProfileMount(key profileKey) { 421 + func stopProfileMount(idx profileIndex) { 419 422 state.mu.Lock() 420 423 defer state.mu.Unlock() 421 - if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil { 422 - state.mountStopping[key] = true 424 + if cmd := state.mountCmds[idx]; cmd != nil && cmd.Process != nil { 425 + state.mountStopping[idx] = true 423 426 interruptProcess(cmd.Process) 424 427 } 425 428 } ··· 427 430 func stopAllMounts() { 428 431 state.mu.Lock() 429 432 defer state.mu.Unlock() 430 - for key, cmd := range state.mountCmds { 433 + for idx, cmd := range state.mountCmds { 431 434 if cmd != nil && cmd.Process != nil { 432 - state.mountStopping[key] = true 435 + state.mountStopping[idx] = true 433 436 interruptProcess(cmd.Process) 434 437 } 435 438 }
+65 -61
cmd/restray/restic.go
··· 17 17 var resticBuiltinPath string 18 18 19 19 var ( 20 - resticOnce sync.Once 20 + resticMu sync.Mutex 21 21 resticPath string 22 22 resticManaged bool 23 23 ) 24 24 25 25 func findRestic() (path string, managed bool) { 26 - resticOnce.Do(func() { 27 - if p, err := exec.LookPath("restic"); err == nil { 28 - resticPath = p 29 - return 26 + resticMu.Lock() 27 + defer resticMu.Unlock() 28 + if resticPath != "" { 29 + return resticPath, resticManaged 30 + } 31 + resticPath, resticManaged = searchRestic() 32 + return resticPath, resticManaged 33 + } 34 + 35 + func searchRestic() (string, bool) { 36 + if p, err := exec.LookPath("restic"); err == nil { 37 + return p, false 38 + } 39 + if p := findResticFromShell(); p != "" { 40 + return p, false 41 + } 42 + if resticBuiltinPath != "" { 43 + if _, err := os.Stat(resticBuiltinPath); err == nil { 44 + return resticBuiltinPath, false 30 45 } 31 - if p := findResticFromShell(); p != "" { 32 - resticPath = p 33 - return 34 - } 35 - if resticBuiltinPath != "" { 36 - if _, err := os.Stat(resticBuiltinPath); err == nil { 37 - resticPath = resticBuiltinPath 38 - return 39 - } 40 - } 41 - if p := managedRestic(); p != "" { 42 - resticPath = p 43 - resticManaged = true 44 - return 45 - } 46 - if p := bundledRestic(); p != "" { 47 - resticPath = p 48 - } 49 - }) 50 - return resticPath, resticManaged 46 + } 47 + if p := managedRestic(); p != "" { 48 + return p, true 49 + } 50 + if p := bundledRestic(); p != "" { 51 + return p, false 52 + } 53 + return "", false 51 54 } 52 55 53 56 func bundledRestic() string { ··· 177 180 return rr, snaps[0].Time 178 181 } 179 182 180 - type profileKey = string 183 + type profileIndex = int 181 184 182 185 type appState struct { 183 186 mu sync.Mutex 184 - busyProfiles map[profileKey]*exec.Cmd 187 + busyProfiles map[profileIndex]*exec.Cmd 185 188 onBusyChanged func(bool) 186 - mountCmds map[profileKey]*exec.Cmd 187 - mountStopping map[profileKey]bool 189 + mountCmds map[profileIndex]*exec.Cmd 190 + mountStopping map[profileIndex]bool 188 191 notifications string 189 - failStatus map[profileKey]string 190 - lastBackup map[profileKey]time.Time 192 + failStatus map[profileIndex]string 193 + lastBackup map[profileIndex]time.Time 191 194 } 192 195 193 196 var state = appState{ 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), 197 + busyProfiles: make(map[profileIndex]*exec.Cmd), 198 + mountCmds: make(map[profileIndex]*exec.Cmd), 199 + mountStopping: make(map[profileIndex]bool), 200 + failStatus: make(map[profileIndex]string), 201 + lastBackup: make(map[profileIndex]time.Time), 199 202 } 200 203 201 - func setLastBackup(key profileKey, t time.Time) { 204 + func setLastBackup(idx profileIndex, t time.Time) { 202 205 state.mu.Lock() 203 - state.lastBackup[key] = t 206 + state.lastBackup[idx] = t 204 207 state.mu.Unlock() 205 208 } 206 209 207 - func getLastBackup(key profileKey) time.Time { 210 + func getLastBackup(idx profileIndex) time.Time { 208 211 state.mu.Lock() 209 212 defer state.mu.Unlock() 210 - return state.lastBackup[key] 213 + return state.lastBackup[idx] 211 214 } 212 215 213 - func isProfileBusy(key profileKey) bool { 216 + func isProfileBusy(idx profileIndex) bool { 214 217 state.mu.Lock() 215 218 defer state.mu.Unlock() 216 - _, ok := state.busyProfiles[key] 219 + _, ok := state.busyProfiles[idx] 217 220 return ok 218 221 } 219 222 ··· 223 226 return len(state.busyProfiles) > 0 224 227 } 225 228 226 - func releaseProfile(key profileKey) { 229 + func releaseProfile(idx profileIndex) { 227 230 state.mu.Lock() 228 - applyBusy(key, false) 231 + wasBusy := len(state.busyProfiles) > 0 232 + delete(state.busyProfiles, idx) 233 + nowBusy := len(state.busyProfiles) > 0 234 + cb := state.onBusyChanged 235 + state.mu.Unlock() 236 + finishBusyChange(wasBusy, nowBusy, cb) 229 237 } 230 238 231 - func acquireProfile(key profileKey) bool { 239 + func acquireProfile(idx profileIndex) bool { 232 240 state.mu.Lock() 233 - if _, busy := state.busyProfiles[key]; busy { 241 + if _, busy := state.busyProfiles[idx]; busy { 234 242 state.mu.Unlock() 235 243 return false 236 244 } 237 - applyBusy(key, true) 245 + wasBusy := len(state.busyProfiles) > 0 246 + state.busyProfiles[idx] = nil 247 + nowBusy := len(state.busyProfiles) > 0 248 + cb := state.onBusyChanged 249 + state.mu.Unlock() 250 + finishBusyChange(wasBusy, nowBusy, cb) 238 251 return true 239 252 } 240 253 241 - func applyBusy(key profileKey, active bool) { 242 - wasBusy := len(state.busyProfiles) > 0 243 - if active { 244 - state.busyProfiles[key] = nil 245 - } else { 246 - delete(state.busyProfiles, key) 247 - } 248 - nowBusy := len(state.busyProfiles) > 0 254 + func finishBusyChange(wasBusy, nowBusy bool, cb func(bool)) { 249 255 if nowBusy && !wasBusy { 250 256 setIconAnimated("busy") 251 257 } 252 - cb := state.onBusyChanged 253 - state.mu.Unlock() 254 258 if cb != nil { 255 259 cb(nowBusy) 256 260 } 257 261 } 258 262 259 - func setProfileBusyCmd(key profileKey, cmd *exec.Cmd) { 263 + func setProfileBusyCmd(idx profileIndex, cmd *exec.Cmd) { 260 264 state.mu.Lock() 261 265 defer state.mu.Unlock() 262 - state.busyProfiles[key] = cmd 266 + state.busyProfiles[idx] = cmd 263 267 } 264 268 265 - func cancelProfile(key profileKey) { 269 + func cancelProfile(idx profileIndex) { 266 270 state.mu.Lock() 267 271 defer state.mu.Unlock() 268 - if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { 272 + if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 269 273 interruptProcess(cmd.Process) 270 274 } 271 275 }
+106 -56
cmd/restray/tray.go
··· 66 66 return activeProfile 67 67 } 68 68 69 - func setProfileFailed(key profileKey, status string) { 69 + func setProfileFailed(idx profileIndex, status string) { 70 70 state.mu.Lock() 71 71 if status == "" { 72 - delete(state.failStatus, key) 72 + delete(state.failStatus, idx) 73 73 } else { 74 - state.failStatus[key] = status 74 + state.failStatus[idx] = status 75 75 } 76 76 state.mu.Unlock() 77 77 } 78 78 79 - func getProfileFailStatus(key profileKey) string { 79 + func getProfileFailStatus(idx profileIndex) string { 80 80 state.mu.Lock() 81 81 defer state.mu.Unlock() 82 - return state.failStatus[key] 82 + return state.failStatus[idx] 83 83 } 84 84 85 85 func anyError() bool { ··· 108 108 109 109 func unreachableRecovered(cfg Config) bool { 110 110 profileMu.Lock() 111 - unreachable := make(map[string]bool) 111 + var unreachable []Profile 112 112 for i, p := range profileStates { 113 113 if p.errMsg == "" && p.repoErr != "" && i < len(cfg.Profiles) { 114 - unreachable[cfg.Profiles[i].displayName()] = true 114 + unreachable = append(unreachable, cfg.Profiles[i]) 115 115 } 116 116 } 117 117 profileMu.Unlock() 118 118 119 - for _, prof := range cfg.Profiles { 120 - if unreachable[prof.displayName()] && repoStatus(prof).errMsg == "" { 119 + for _, prof := range unreachable { 120 + if repoStatus(prof).errMsg == "" { 121 121 return true 122 122 } 123 123 } ··· 216 216 sched := cron.New() 217 217 var applyConfig func() 218 218 var applyMu sync.Mutex 219 + pendingFullApply := false 219 220 220 221 state.onBusyChanged = func(busy bool) { 221 222 if busy { ··· 306 307 paused := !prof.Schedule.OnBattery && onBatteryPower() 307 308 if prof.Schedule.Cron == "" { 308 309 if cfg.GUI.ScheduleDisplay == "last" { 309 - last := formatLastBackup(getLastBackup(prof.displayName())) 310 + last := formatLastBackup(getLastBackup(i)) 310 311 return prefix + "Unscheduled, last " + last 311 312 } 312 313 return prefix + "Unscheduled" ··· 328 329 } 329 330 return prefix + prof.Schedule.Cron 330 331 case "last": 331 - last := formatLastBackup(getLastBackup(prof.displayName())) 332 + last := formatLastBackup(getLastBackup(i)) 332 333 if next != "" { 333 334 return prefix + next + ", last " + last 334 335 } ··· 346 347 updateAllStatusItems := func(cfg Config) { 347 348 mGlobalStatus.Hide() 348 349 for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { 349 - key := cfg.Profiles[i].displayName() 350 - if !isProfileBusy(key) { 351 - if failMsg := getProfileFailStatus(key); failMsg != "" { 350 + if !isProfileBusy(i) { 351 + if failMsg := getProfileFailStatus(i); failMsg != "" { 352 352 statusItem(i).SetTitle(failMsg) 353 353 } else { 354 354 mStatusItems[i].SetTitle(profileStatusText(cfg, i)) ··· 371 371 ps := profileStates[idx] 372 372 profileMu.Unlock() 373 373 prof := cfg.Profiles[idx] 374 - key := prof.displayName() 375 374 376 375 createOrEdit(mEnv, "Env File", prof.EnvFile) 377 376 if envFilesInsecure(cfg.Profiles) { ··· 388 387 389 388 if runtime.GOOS == "windows" { 390 389 mMount.Hide() 391 - } else if isProfileMounted(key) { 390 + } else if isProfileMounted(idx) { 392 391 mMount.SetTitle("Unmount") 393 392 } else { 394 393 mMount.SetTitle("Mount") 395 394 } 396 395 397 - if isProfileBusy(key) { 396 + if isProfileBusy(idx) { 398 397 mCancel.Show() 399 398 mRepo.Enable() 400 399 mBackup.Disable() ··· 427 426 } 428 427 } 429 428 429 + probeProfileState := func(cfg Config, i int, prof Profile) profileState { 430 + var ps profileState 431 + ps.errMsg = prof.configError() 432 + if ps.errMsg == "" { 433 + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() 434 + var rs repoResult 435 + if wantsLast { 436 + var last time.Time 437 + rs, last = repoStatusAndLastBackup(prof) 438 + if !last.IsZero() { 439 + setLastBackup(i, last) 440 + } 441 + } else { 442 + rs = repoStatus(prof) 443 + } 444 + ps.repoErr = rs.errMsg 445 + ps.needsInit = rs.needsInit 446 + } 447 + return ps 448 + } 449 + 450 + refreshProfile := func(idx int) { 451 + applyMu.Lock() 452 + cfg := loadConfig() 453 + if idx < 0 || idx >= len(cfg.Profiles) || idx >= len(profileStates) || idx >= maxProfiles { 454 + applyMu.Unlock() 455 + applyConfig() 456 + return 457 + } 458 + defer applyMu.Unlock() 459 + prof := cfg.Profiles[idx] 460 + mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") 461 + ps := probeProfileState(cfg, idx, prof) 462 + profileMu.Lock() 463 + if idx < len(profileStates) { 464 + profileStates[idx] = ps 465 + } 466 + profileMu.Unlock() 467 + if !isProfileBusy(idx) { 468 + if failMsg := getProfileFailStatus(idx); failMsg != "" { 469 + statusItem(idx).SetTitle(failMsg) 470 + } else { 471 + mStatusItems[idx].SetTitle(profileStatusText(cfg, idx)) 472 + } 473 + } 474 + if !isAnyBusy() { 475 + if anyError() { 476 + setIconAnimated("fail") 477 + } else { 478 + setIconAnimated("idle") 479 + } 480 + } 481 + applyProfileUI(cfg) 482 + } 483 + 484 + finishProfile := func(idx int) { 485 + applyMu.Lock() 486 + pending := pendingFullApply 487 + pendingFullApply = false 488 + applyMu.Unlock() 489 + if pending { 490 + applyConfig() 491 + return 492 + } 493 + refreshProfile(idx) 494 + } 495 + 430 496 applyConfig = func() { 431 497 applyMu.Lock() 432 498 defer applyMu.Unlock() 433 499 if isAnyBusy() { 500 + pendingFullApply = true 434 501 cfg := loadConfig() 435 502 if applyIconMode(cfg.GUI.Icon) { 436 503 refreshIcon() ··· 447 514 applyProfileUI(cfg) 448 515 return 449 516 } 517 + pendingFullApply = false 450 518 cfg := loadConfig() 451 519 applyIconMode(cfg.GUI.Icon) 452 520 state.mu.Lock() ··· 514 582 wg.Add(1) 515 583 go func(i int, prof Profile) { 516 584 defer wg.Done() 517 - var ps profileState 518 - ps.errMsg = prof.configError() 519 - if ps.errMsg == "" { 520 - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.displayName()).IsZero() 521 - var rs repoResult 522 - if wantsLast { 523 - var last time.Time 524 - rs, last = repoStatusAndLastBackup(prof) 525 - if !last.IsZero() { 526 - setLastBackup(prof.displayName(), last) 527 - } 528 - } else { 529 - rs = repoStatus(prof) 530 - } 531 - ps.repoErr = rs.errMsg 532 - ps.needsInit = rs.needsInit 533 - } 585 + ps := probeProfileState(cfg, i, prof) 534 586 535 587 if ps.errMsg == "" && prof.Schedule.Cron != "" { 536 588 name := prof.displayName() 537 589 eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 538 590 current := loadConfig() 539 - prof, err := resolveProfile(current, name) 591 + prof, err := resolveProfileAt(current, i, name) 540 592 if err != nil { 541 593 log.Printf("[%s] skipping scheduled backup: %v", name, err) 542 594 return ··· 551 603 log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 552 604 } 553 605 }() 554 - runScheduled(prof.displayName(), statusItem(i), prof, applyConfig) 606 + runScheduled(i, statusItem(i), prof, func() { finishProfile(i) }) 555 607 }() 556 608 }) 557 609 if err != nil { ··· 659 711 repoOp := func(status string, args ...string) { 660 712 idx := getActiveProfile() 661 713 prof := selectedProfile() 662 - key := prof.displayName() 663 - if idx >= maxProfiles || isProfileBusy(key) { 714 + if idx >= maxProfiles || isProfileBusy(idx) { 664 715 return 665 716 } 666 717 ms := statusItem(idx) 667 718 go func() { 668 - if !acquireProfile(key) { 719 + if !acquireProfile(idx) { 669 720 return 670 721 } 671 - setProfileFailed(key, "") 672 - defer applyConfig() 673 - defer releaseProfile(key) 722 + setProfileFailed(idx, "") 723 + defer finishProfile(idx) 724 + defer releaseProfile(idx) 674 725 ms.SetTitle(status) 675 - if msg, err := runRestic(key, prof, ms, args...); err != nil { 676 - setProfileFailed(key, msg) 726 + if msg, err := runRestic(idx, prof, ms, args...); err != nil { 727 + setProfileFailed(idx, msg) 677 728 notifyError(args[0], msg) 678 729 } else { 679 730 notifySuccess(args[0]) ··· 687 738 case <-mSchedule.ClickedCh: 688 739 idx := getActiveProfile() 689 740 prof := selectedProfile() 690 - key := prof.displayName() 691 - if idx < maxProfiles && !isProfileBusy(key) { 692 - go runScheduled(key, statusItem(idx), prof, applyConfig) 741 + if idx < maxProfiles && !isProfileBusy(idx) { 742 + go runScheduled(idx, statusItem(idx), prof, func() { finishProfile(idx) }) 693 743 } 694 744 case <-mBackup.ClickedCh: 695 745 idx := getActiveProfile() 696 746 prof := selectedProfile() 697 - key := prof.displayName() 698 - if idx < maxProfiles && !isProfileBusy(key) { 699 - go runBackup(key, statusItem(idx), prof, applyConfig) 747 + if idx < maxProfiles && !isProfileBusy(idx) { 748 + go runBackup(idx, statusItem(idx), prof, func() { finishProfile(idx) }) 700 749 } 701 750 case <-mCancel.ClickedCh: 702 - cancelProfile(selectedProfile().displayName()) 751 + idx := getActiveProfile() 752 + cancelProfile(idx) 703 753 case <-mPrune.ClickedCh: 704 754 prof := selectedProfile() 705 755 if len(prof.Prune.Args) > 0 { ··· 712 762 case <-mInit.ClickedCh: 713 763 repoOp("Initializing repository...", "init") 714 764 case <-mMount.ClickedCh: 765 + idx := getActiveProfile() 715 766 prof := selectedProfile() 716 - key := prof.displayName() 717 - if isProfileMounted(key) { 718 - stopProfileMount(key) 767 + if isProfileMounted(idx) { 768 + stopProfileMount(idx) 719 769 } else { 720 - go startMount(key, prof, mMount, applyConfig) 770 + go startMount(idx, prof, mMount, func() { finishProfile(idx) }) 721 771 } 722 772 case <-mConsole.ClickedCh: 723 773 cfg := loadConfig() ··· 807 857 } 808 858 }() 809 859 810 - if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().General.UpdatesEnabled() { 860 + if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { 811 861 go func() { 812 862 check := func() { 813 863 latest, err := latestVersion()
+18 -14
cmd/restray/update.go
··· 49 49 for _, e := range feed.Entries { 50 50 t := strings.TrimSpace(strings.TrimPrefix(e.Title, "[Tag]")) 51 51 v := strings.TrimPrefix(t, "v") 52 - if !looksLikeVersion(v) { 52 + if _, ok := parseVersion(v); !ok { 53 53 continue 54 54 } 55 55 if best == "" || compareVersion(v, best) > 0 { ··· 62 62 return best, nil 63 63 } 64 64 65 - func looksLikeVersion(v string) bool { 65 + func parseVersion(v string) ([3]int, bool) { 66 66 parts := strings.Split(v, ".") 67 67 if len(parts) != 3 { 68 - return false 68 + return [3]int{}, false 69 69 } 70 - for _, p := range parts { 71 - if _, err := strconv.Atoi(p); err != nil { 72 - return false 70 + var nums [3]int 71 + for i, p := range parts { 72 + n, err := strconv.Atoi(p) 73 + if err != nil { 74 + return [3]int{}, false 73 75 } 76 + nums[i] = n 74 77 } 75 - return true 78 + return nums, true 76 79 } 77 80 78 81 func compareVersion(a, b string) int { 79 - pa := strings.Split(a, ".") 80 - pb := strings.Split(b, ".") 81 - for i := 0; i < 3; i++ { 82 - na, _ := strconv.Atoi(pa[i]) 83 - nb, _ := strconv.Atoi(pb[i]) 84 - if na != nb { 85 - return na - nb 82 + pa, okA := parseVersion(a) 83 + pb, okB := parseVersion(b) 84 + if !okA || !okB { 85 + return 0 86 + } 87 + for i := range pa { 88 + if pa[i] != pb[i] { 89 + return pa[i] - pb[i] 86 90 } 87 91 } 88 92 return 0