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.

refactor: stabilize profile state, improve CLI flags

- track tray state by profile name
- move runtime flags into the cli so they appear in help and shell completions
- consolidate backend retry handling
- make `--system` linux only
- other improvements

intergrav (Jul 11, 2026, 10:52 AM EDT) 3e1f79c4 35421c77

+420 -370
+99 -64
cmd/restray/cli.go
··· 13 13 "runtime" 14 14 "strings" 15 15 "sync" 16 - "sync/atomic" 17 16 "syscall" 18 17 19 18 "github.com/google/shlex" ··· 21 20 "github.com/urfave/cli/v3" 22 21 ) 23 22 23 + const ( 24 + systemConfigDir = "/etc/restray" 25 + systemStateDir = "/var/lib/restray" 26 + ) 27 + 28 + func applyRuntimeContext(configDir, stateDir string, setHome bool) { 29 + if configDir != "" { 30 + configDirOverride = configDir 31 + } 32 + if stateDir != "" { 33 + dataDirOverride = stateDir 34 + } 35 + if setHome && dataDirOverride != "" { 36 + os.Setenv("HOME", dataDirOverride) 37 + } 38 + } 39 + 40 + func configureRuntime(ctx context.Context, cmd *cli.Command) (context.Context, error) { 41 + configDir := cmd.String("config") 42 + stateDir := cmd.String("state") 43 + system := runtime.GOOS == "linux" && cmd.Bool("system") 44 + if system { 45 + if configDir == "" { 46 + configDir = systemConfigDir 47 + } 48 + if stateDir == "" { 49 + stateDir = systemStateDir 50 + } 51 + } 52 + applyRuntimeContext(configDir, stateDir, system) 53 + return ctx, nil 54 + } 55 + 24 56 func resolveProfile(cfg Config, name string) (Profile, error) { 57 + if cfg.loadErr != nil { 58 + return Profile{}, cfg.loadErr 59 + } 25 60 if name == "" { 26 61 return cfg.Profiles[0], nil 27 62 } 28 - var found Profile 29 - matches := 0 30 63 for _, p := range cfg.Profiles { 31 64 if p.displayName() == name { 32 - found = p 33 - matches++ 65 + return p, nil 34 66 } 35 - } 36 - if matches == 1 { 37 - return found, nil 38 - } 39 - if matches > 1 { 40 - return Profile{}, fmt.Errorf("profile %q is ambiguous (matched %d profiles)", name, matches) 41 67 } 42 68 var names []string 43 69 for _, p := range cfg.Profiles { ··· 46 72 return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) 47 73 } 48 74 49 - func resolveProfileAt(cfg Config, idx int, name string) (Profile, error) { 50 - if idx >= 0 && idx < len(cfg.Profiles) && cfg.Profiles[idx].displayName() == name { 51 - return cfg.Profiles[idx], nil 75 + func resolveProfileIndex(cfg Config, key stateKey) (int, Profile, error) { 76 + if cfg.loadErr != nil { 77 + return 0, Profile{}, cfg.loadErr 78 + } 79 + for idx, prof := range cfg.Profiles { 80 + if prof.profileKey() == key { 81 + return idx, prof, nil 82 + } 52 83 } 53 - return resolveProfile(cfg, name) 84 + return 0, Profile{}, fmt.Errorf("profile no longer exists") 54 85 } 55 86 56 87 func editorCmd() string { ··· 67 98 } 68 99 69 100 func cliWeb(context.Context, *cli.Command) error { 70 - ensureConfigFile() 101 + if err := ensureConfigFile(); err != nil { 102 + return cli.Exit("error: "+err.Error(), 1) 103 + } 71 104 url, err := ensureWebEditor() 72 105 if err != nil { 73 106 return cli.Exit(fmt.Sprintf("error: %v", err), 1) ··· 121 154 } 122 155 123 156 func cliRunBackendWithRetry(prof Profile, args []string) error { 124 - return cliCommandError(runWithRetryLock(prof, args, runCLIBackend)) 125 - } 126 - 127 - func cliRunCmdLogged(prof Profile, cmd *exec.Cmd) error { 128 - return cliCommandError(runLoggedCommand(prof, cmd)) 157 + return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error { 158 + return runCLIBackend(prof, args...) 159 + })) 129 160 } 130 161 131 162 func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { ··· 159 190 } 160 191 161 192 func cliRunScheduledBackend(prof Profile, args []string) error { 162 - return cliCommandError(runWithRetryLock(prof, args, runCLIBackendLogged)) 163 - } 164 - 165 - func runWithRetryLock(prof Profile, args []string, run func(Profile, ...string) error) error { 166 - err := run(prof, args...) 167 - if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 168 - log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 169 - _ = run(prof, "unlock") 170 - err = run(prof, args...) 171 - } 172 - return err 193 + return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error { 194 + return runCLIBackendLogged(prof, args...) 195 + })) 173 196 } 174 197 175 198 func cliCommandError(err error) error { ··· 199 222 if hook == "" { 200 223 return cli.Exit("error: no hook configured", 1) 201 224 } 202 - return cliRunCmdLogged(prof, hookCmd(hook, prof, extraEnv...)) 225 + return cliCommandError(runLoggedCommand(prof, hookCmd(hook, prof, extraEnv...))) 203 226 } 204 227 205 228 func cliBackup(prof Profile, scheduled bool) error { ··· 221 244 return cliRunBackend(prof, "unlock") 222 245 } 223 246 224 - func cliPreHook(prof Profile) error { 225 - return cliRunHook(prof.PreHook, prof) 226 - } 227 - 228 - func cliPostHook(prof Profile) error { 229 - return cliRunHook(prof.PostHook, prof) 230 - } 231 - 232 247 func cliShell(prof Profile) error { 233 248 shell := os.Getenv("SHELL") 234 249 if shell == "" { ··· 271 286 return cli.Exit("error: "+prof.backendName()+" not found", 1) 272 287 } 273 288 274 - var interrupted atomic.Bool 275 - sigCh := make(chan os.Signal, 1) 276 - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) 277 - defer signal.Reset(os.Interrupt, syscall.SIGTERM) 278 - go func() { 279 - <-sigCh 280 - interrupted.Store(true) 281 - }() 289 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 290 + defer stop() 282 291 283 292 hookEnv := []string{ 284 293 "RESTRAY_OPERATIONS=" + strings.Join(scheduledOpNames(prof), ","), ··· 294 303 295 304 failed, failMsg := runScheduledOperations( 296 305 prof, 297 - interrupted.Load, 306 + func() bool { return ctx.Err() != nil }, 298 307 func() (bool, string) { 299 308 log.Printf("[%s] running backup", prof.displayName()) 300 309 if err := cliRunScheduledBackend(prof, backupArgs(prof, true)); err != nil { ··· 318 327 }, 319 328 ) 320 329 330 + interrupted := ctx.Err() != nil 321 331 if prof.PostHook != "" { 322 - signal.Reset(os.Interrupt, syscall.SIGTERM) 332 + stop() 323 333 log.Printf("[%s] running post-hook", prof.displayName()) 324 334 postEnv := hookEnv 325 - if failed || interrupted.Load() { 335 + if failed || interrupted { 326 336 errMsg := failMsg 327 337 if errMsg == "" { 328 338 errMsg = "operation failed" 329 339 } 330 - if interrupted.Load() { 340 + if interrupted { 331 341 errMsg = "interrupted by user" 332 342 } 333 343 postEnv = append(postEnv, "RESTRAY_ERROR="+errMsg) ··· 337 347 } 338 348 } 339 349 340 - if failed || interrupted.Load() { 350 + if failed || interrupted { 341 351 return cli.Exit("error: schedule failed", 1) 342 352 } 343 353 return nil 344 354 } 345 355 346 356 func buildDaemonSchedule() (*cron.Cron, error) { 357 + cfg := loadConfig() 358 + if cfg.loadErr != nil { 359 + return nil, cfg.loadErr 360 + } 347 361 sched := cron.New() 348 - for idx, prof := range loadConfig().Profiles { 362 + for _, prof := range cfg.Profiles { 349 363 if prof.Schedule.Cron == "" { 350 364 continue 351 365 } 366 + key := prof.profileKey() 352 367 name := prof.displayName() 353 368 if errMsg := prof.scheduleError(); errMsg != "" { 354 369 log.Printf("[%s] skipping schedule: %s", name, errMsg) ··· 356 371 } 357 372 _, err := sched.AddFunc(prof.Schedule.Cron, func() { 358 373 current := loadConfig() 359 - prof, err := resolveProfileAt(current, idx, name) 374 + _, prof, err := resolveProfileIndex(current, key) 360 375 if err != nil { 361 376 log.Printf("[%s] skipping scheduled run: %v", name, err) 362 377 return ··· 365 380 log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) 366 381 return 367 382 } 368 - if !acquireProfile(idx) { 383 + if !acquireProfile(key) { 369 384 log.Printf("[%s] skipping scheduled run: profile already busy", prof.displayName()) 370 385 return 371 386 } 372 - defer releaseProfile(idx) 387 + defer releaseProfile(key) 373 388 defer func() { 374 389 if r := recover(); r != nil { 375 390 log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) ··· 443 458 cli.VersionPrinter = func(cmd *cli.Command) { 444 459 fmt.Fprintf(cmd.Root().Writer, "restray %s compiled with %s on %s/%s\n", cmd.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) 445 460 } 461 + flags := []cli.Flag{ 462 + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "configuration directory"}, 463 + &cli.StringFlag{Name: "state", Aliases: []string{"s"}, Usage: "state directory"}, 464 + } 465 + if runtime.GOOS == "linux" { 466 + flags = append(flags, &cli.BoolFlag{Name: "system", Usage: "use system configuration and state directories"}) 467 + } 468 + flags = append(flags, 469 + &cli.BoolFlag{Name: "verbose", Usage: "also write tray logs to stderr"}, 470 + profileFlag(), 471 + ) 446 472 return &cli.Command{ 447 473 Name: "restray", 448 - Usage: "Restic and Rustic backup scheduler and tray app", 474 + Usage: "A powerful but friendly backup program powered by restic", 449 475 Version: version, 450 476 EnableShellCompletion: true, 451 - Flags: []cli.Flag{profileFlag()}, 477 + Before: configureRuntime, 478 + Action: func(_ context.Context, cmd *cli.Command) error { 479 + runTray(cmd.Bool("verbose")) 480 + return nil 481 + }, 482 + Flags: flags, 452 483 Commands: []*cli.Command{ 453 484 { 454 485 Name: "daemon", ··· 471 502 {Name: "unlock", Usage: "Remove stale locks", Action: profileAction(cliUnlock)}, 472 503 {Name: "shell", Usage: "Open shell with selected backend environment", Action: profileAction(cliShell)}, 473 504 {Name: "mount", Usage: "Mount repository (Ctrl+C to unmount)", Action: profileAction(cliMount)}, 474 - {Name: "pre-hook", Usage: "Run pre-hook command", Action: profileAction(cliPreHook)}, 475 - {Name: "post-hook", Usage: "Run post-hook command", Action: profileAction(cliPostHook)}, 505 + {Name: "pre-hook", Usage: "Run pre-hook command", Action: profileAction(func(p Profile) error { return cliRunHook(p.PreHook, p) })}, 506 + {Name: "post-hook", Usage: "Run post-hook command", Action: profileAction(func(p Profile) error { return cliRunHook(p.PostHook, p) })}, 476 507 }, 477 508 }, 478 509 { ··· 484 515 Name: "config", 485 516 Usage: "Create/open config file in editor", 486 517 Action: func(context.Context, *cli.Command) error { 487 - ensureConfigFile() 518 + if err := ensureConfigFile(); err != nil { 519 + return cli.Exit("error: "+err.Error(), 1) 520 + } 488 521 return cliEdit(configPath()) 489 522 }, 490 523 }, ··· 492 525 Name: "env", 493 526 Usage: "Create/open env file in editor", 494 527 Action: profileAction(func(p Profile) error { 495 - ensureEnvFile(p.EnvFile) 528 + if err := ensureEnvFile(p.EnvFile); err != nil { 529 + return cli.Exit("error: "+err.Error(), 1) 530 + } 496 531 return cliEdit(p.EnvFile) 497 532 }), 498 533 },
+47 -18
cmd/restray/config.go
··· 68 68 Mount Mount `toml:"mount"` 69 69 } 70 70 71 + func (prof Profile) profileKey() stateKey { 72 + return stateKey(prof.displayName()) 73 + } 74 + 71 75 type GUI struct { 72 76 CheckUpdates *bool `toml:"check_updates"` 73 77 ManageBackend *bool `toml:"manage_backend"` ··· 83 87 } 84 88 85 89 func (g GUI) BackendManagementEnabled() bool { 86 - return g.ManageBackend != nil && *g.ManageBackend || g.ManageBackend == nil && g.ManageRestic 90 + if g.ManageBackend != nil { 91 + return *g.ManageBackend 92 + } 93 + return g.ManageRestic 87 94 } 88 95 89 96 type Config struct { ··· 299 306 } 300 307 } 301 308 } 309 + if cfg.loadErr == nil { 310 + names := make(map[string]struct{}, len(cfg.Profiles)) 311 + for _, prof := range cfg.Profiles { 312 + name := prof.displayName() 313 + if _, exists := names[name]; exists { 314 + cfg.loadErr = fmt.Errorf("duplicate profile name %q", name) 315 + log.Printf("config: %s", configLoadErrorMessage(cfg.loadErr)) 316 + break 317 + } 318 + names[name] = struct{}{} 319 + } 320 + } 302 321 return cfg 303 322 } 304 323 ··· 306 325 return strings.ReplaceAll(defaultConfigTemplate, "{{ENV_FILE}}", filepath.ToSlash(defaultEnvPath("default"))) 307 326 } 308 327 309 - func ensureConfigFile() { 328 + func ensureConfigFile() error { 310 329 p := configPath() 311 330 if _, err := os.Stat(p); err == nil { 312 - return 331 + return nil 332 + } else if !os.IsNotExist(err) { 333 + return err 313 334 } 314 - os.MkdirAll(filepath.Dir(p), 0700) 315 - os.WriteFile(p, []byte(defaultConfigContents()), 0600) 335 + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { 336 + return err 337 + } 338 + return os.WriteFile(p, []byte(defaultConfigContents()), 0600) 316 339 } 317 340 318 - func fileWritable(path string) bool { 319 - f, err := os.OpenFile(path, os.O_WRONLY, 0) 341 + func configWritable() bool { 342 + f, err := os.OpenFile(configPath(), os.O_WRONLY, 0) 320 343 if err != nil { 321 - return !os.IsPermission(err) 344 + return false 322 345 } 323 346 f.Close() 324 347 return true 325 - } 326 - 327 - func configWritable() bool { 328 - return fileWritable(configPath()) 329 348 } 330 349 331 350 func saveConfig(cfg Config) error { ··· 339 358 return os.WriteFile(p, buf.Bytes(), 0600) 340 359 } 341 360 342 - func ensureEnvFile(path string) { 361 + func ensureEnvFile(path string) error { 343 362 if _, err := os.Stat(path); err == nil { 344 - return 363 + return nil 364 + } else if !os.IsNotExist(err) { 365 + return err 345 366 } 346 - os.MkdirAll(filepath.Dir(path), 0700) 347 - os.WriteFile(path, []byte(defaultEnvTemplate), 0600) 367 + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { 368 + return err 369 + } 370 + return os.WriteFile(path, []byte(defaultEnvTemplate), 0600) 348 371 } 349 372 350 373 func fileExists(path string) bool { ··· 355 378 func watchConfig(onChange func()) { 356 379 watcher, err := fsnotify.NewWatcher() 357 380 if err != nil { 381 + log.Printf("config: failed to watch config directory: %v", err) 358 382 return 359 383 } 360 - watcher.Add(configDir()) 384 + if err := watcher.Add(configDir()); err != nil { 385 + log.Printf("config: failed to watch %s: %v", configDir(), err) 386 + watcher.Close() 387 + return 388 + } 361 389 362 390 go func() { 363 - for range watcher.Errors { 391 + for err := range watcher.Errors { 392 + log.Printf("config watch: %v", err) 364 393 } 365 394 }() 366 395 go func() {
+9 -76
cmd/restray/main.go
··· 4 4 "io" 5 5 "log" 6 6 "os" 7 - "runtime" 8 - "strings" 9 7 10 8 "fyne.io/systray" 11 9 "github.com/gen2brain/beeep" ··· 13 11 14 12 const version = "0.21.1" 15 13 16 - const ( 17 - systemConfigDir = "/etc/restray" 18 - systemStateDir = "/var/lib/restray" 19 - ) 20 - 21 - func applyRuntimeContext(configDir, stateDir string, setHome bool) { 22 - if configDir != "" { 23 - configDirOverride = configDir 24 - } 25 - if stateDir != "" { 26 - dataDirOverride = stateDir 27 - } 28 - if setHome && dataDirOverride != "" { 29 - os.Setenv("HOME", dataDirOverride) 30 - } 14 + func main() { 15 + os.Exit(runCLI(os.Args[1:])) 31 16 } 32 17 33 - func parsePathFlag(args []string, i *int, name string) string { 34 - if value, ok := strings.CutPrefix(args[*i], name+"="); ok { 35 - if value == "" { 36 - log.Fatalf("restray: %s requires a non-empty value", name) 37 - } 38 - return value 18 + func runTray(verbose bool) { 19 + if err := os.MkdirAll(configDir(), 0700); err != nil { 20 + log.Printf("restray: create config directory: %v", err) 21 + return 39 22 } 40 - if *i+1 >= len(args) { 41 - log.Fatalf("restray: %s requires a value", name) 23 + if err := os.MkdirAll(dataDir(), 0700); err != nil { 24 + log.Printf("restray: create state directory: %v", err) 25 + return 42 26 } 43 - *i++ 44 - return args[*i] 45 - } 46 - 47 - func preParseGlobalFlags() (verbose bool, remaining []string) { 48 - args := os.Args[1:] 49 - var system bool 50 - for i := 0; i < len(args); i++ { 51 - arg := args[i] 52 - switch arg { 53 - case "-v", "--verbose": 54 - verbose = true 55 - case "--system": 56 - if runtime.GOOS != "linux" { 57 - log.Fatalf("restray: --system is not supported on %s", runtime.GOOS) 58 - } 59 - system = true 60 - default: 61 - switch { 62 - case arg == "-c" || arg == "--config" || strings.HasPrefix(arg, "--config="): 63 - applyRuntimeContext(parsePathFlag(args, &i, "--config"), "", false) 64 - case arg == "--state" || strings.HasPrefix(arg, "--state="): 65 - applyRuntimeContext("", parsePathFlag(args, &i, "--state"), false) 66 - default: 67 - remaining = append(remaining, arg) 68 - } 69 - } 70 - } 71 - if system { 72 - configDir := configDirOverride 73 - if configDir == "" { 74 - configDir = systemConfigDir 75 - } 76 - stateDir := dataDirOverride 77 - if stateDir == "" { 78 - stateDir = systemStateDir 79 - } 80 - applyRuntimeContext(configDir, stateDir, true) 81 - } 82 - return verbose, remaining 83 - } 84 - 85 - func main() { 86 - verbose, args := preParseGlobalFlags() 87 - 88 - if len(args) > 0 { 89 - os.Exit(runCLI(args)) 90 - } 91 - 92 - os.MkdirAll(configDir(), 0700) 93 - os.MkdirAll(dataDir(), 0700) 94 27 95 28 lock, err := os.OpenFile(lockPath(), os.O_CREATE|os.O_RDWR, 0600) 96 29 if err != nil || lockFile(lock) != nil {
+85 -62
cmd/restray/operations.go
··· 68 68 }) 69 69 } 70 70 71 - func runBackendOnce(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 71 + func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 72 72 cmd := backendCmd(prof, args...) 73 73 var stdout strings.Builder 74 74 cmd.Stdout = &stdout 75 75 stderrPipe, _ := cmd.StderrPipe() 76 - setProfileBusyCmd(idx, cmd) 76 + setProfileBusyCmd(key, cmd) 77 77 78 78 if err := cmd.Start(); err != nil { 79 79 log.Printf("[%s] start failed: %v", prof.displayName(), err) ··· 101 101 return prof.backend() == BackendRestic && prof.RetryLock != "" && prof.RetryLock != "0" 102 102 } 103 103 104 - func retryUnlock(idx profileIndex, prof Profile, mStatus prefixedMenuItem) { 104 + func runBackendWithRetry(prof Profile, args []string, run func(...string) error) error { 105 + err := run(args...) 106 + if err == nil || exitCode(err) != 11 || !retryLockEnabled(prof) { 107 + return err 108 + } 105 109 log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 106 - mStatus.SetTitle("Unlocking repository...") 107 - runBackendOnce(idx, prof, mStatus, "unlock") 110 + if err := run("unlock"); err != nil { 111 + return err 112 + } 113 + return run(args...) 108 114 } 109 115 110 - func runBackend(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 111 - if retryLockEnabled(prof) { 112 - args = append(args, "--retry-lock", prof.RetryLock) 113 - } 114 - msg, err := runBackendOnce(idx, prof, mStatus, args...) 115 - if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 116 - retryUnlock(idx, prof, mStatus) 117 - msg, err = runBackendOnce(idx, prof, mStatus, args...) 118 - } 116 + func runBackend(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 117 + args = addRetryLockArgs(prof, args) 118 + var msg string 119 + err := runBackendWithRetry(prof, args, func(args ...string) error { 120 + if len(args) == 1 && args[0] == "unlock" { 121 + mStatus.SetTitle("Unlocking repository...") 122 + } 123 + var err error 124 + msg, err = runBackendOnce(key, prof, mStatus, args...) 125 + return err 126 + }) 119 127 return msg, err 120 128 } 121 129 122 - func doBackupOnce(idx profileIndex, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 130 + func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int, error) { 123 131 cmd := backendCmd(prof, args...) 124 132 stdoutPipe, _ := cmd.StdoutPipe() 125 133 stderrPipe, _ := cmd.StderrPipe() 126 - setProfileBusyCmd(idx, cmd) 134 + setProfileBusyCmd(key, cmd) 127 135 128 136 if err := cmd.Start(); err != nil { 129 137 log.Printf("[%s] start failed: %v", prof.displayName(), err) 130 - setProfileFailed(idx, err.Error()) 131 - return false, -1 138 + setProfileFailed(key, err.Error()) 139 + return false, -1, err 132 140 } 133 141 134 142 sl := streamStderr(stderrPipe, prof, mStatus) 135 143 136 144 scanner := bufio.NewScanner(stdoutPipe) 145 + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) 137 146 for scanner.Scan() { 138 147 line := scanner.Text() 139 148 ··· 164 173 logPrefixedLine(prof.displayName(), line) 165 174 } 166 175 } 176 + if err := scanner.Err(); err != nil { 177 + log.Printf("[%s] reading backup output: %v", prof.displayName(), err) 178 + } 167 179 168 180 if err := cmd.Wait(); err != nil { 169 181 msg, code := classifyBackendError(prof, err, sl.lastLine()) 170 182 log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) 171 183 if prof.backend() == BackendRestic && code == 3 { 172 184 log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) 173 - return true, 3 185 + return true, 3, nil 174 186 } 175 - setProfileFailed(idx, msg) 176 - return false, code 187 + setProfileFailed(key, msg) 188 + return false, code, err 177 189 } 178 190 log.Printf("[%s] done: backup", prof.displayName()) 179 - return true, 0 191 + return true, 0, nil 180 192 } 181 193 182 - func doBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 194 + func doBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 183 195 jsonFlag := "--json" 184 196 if prof.backend() == BackendRustic { 185 197 jsonFlag = "--json-progress" 186 198 } 187 199 args := backupArgs(prof, scheduled, jsonFlag) 188 200 189 - ok, code := doBackupOnce(idx, mStatus, prof, args) 190 - if !ok && code == 11 && retryLockEnabled(prof) { 191 - retryUnlock(idx, prof, mStatus) 192 - mStatus.SetTitle("Backing up...") 193 - setProfileFailed(idx, "") 194 - ok, code = doBackupOnce(idx, mStatus, prof, args) 201 + var ok bool 202 + var code int 203 + err := runBackendWithRetry(prof, args, func(args ...string) error { 204 + if len(args) == 1 && args[0] == "unlock" { 205 + mStatus.SetTitle("Unlocking repository...") 206 + } else { 207 + mStatus.SetTitle("Backing up...") 208 + setProfileFailed(key, "") 209 + } 210 + var err error 211 + ok, code, err = doBackupOnce(key, mStatus, prof, args) 212 + return err 213 + }) 214 + if err != nil { 215 + return false, code 195 216 } 196 217 if ok { 197 - setLastBackup(idx, time.Now()) 218 + setLastBackup(key, time.Now()) 198 219 } 199 220 return ok, code 200 221 } 201 222 202 - func runBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 223 + func runBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 203 224 if p, _ := findBackend(prof); p == "" { 204 225 return 205 226 } 206 - if !acquireProfile(idx) { 227 + if !acquireProfile(key) { 207 228 return 208 229 } 209 - setProfileFailed(idx, "") 230 + setProfileFailed(key, "") 210 231 defer onDone() 211 - defer releaseProfile(idx) 232 + defer releaseProfile(key) 212 233 213 234 mStatus.SetTitle("Backing up...") 214 - ok, code := doBackup(idx, mStatus, prof, false) 235 + ok, code := doBackup(key, mStatus, prof, false) 215 236 if ok { 216 237 if code == 3 { 217 238 notifyError("Backup", "Completed with warnings: some files could not be read") ··· 219 240 notifySuccess("Backup") 220 241 } 221 242 } else { 222 - notifyError("Backup", getProfileFailStatus(idx)) 243 + notifyError("Backup", getProfileFailStatus(key)) 223 244 } 224 245 } 225 246 ··· 323 344 return failed, failMsg 324 345 } 325 346 326 - func runScheduled(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 347 + func runScheduled(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) { 327 348 if p, _ := findBackend(prof); p == "" { 328 349 return 329 350 } 330 - if !acquireProfile(idx) { 351 + if !acquireProfile(key) { 331 352 return 332 353 } 333 - setProfileFailed(idx, "") 354 + setProfileFailed(key, "") 334 355 defer onDone() 335 - defer releaseProfile(idx) 356 + defer releaseProfile(key) 336 357 337 358 hookEnv := []string{ 338 359 "RESTRAY_OPERATIONS=" + strings.Join(scheduledOpNames(prof), ","), ··· 342 363 if prof.PreHook != "" { 343 364 mStatus.SetTitle("Running pre-hook...") 344 365 if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 345 - setProfileFailed(idx, "Pre-hook failed") 366 + setProfileFailed(key, "Pre-hook failed") 346 367 notifyError("Schedule", "Pre-hook failed") 347 368 return 348 369 } ··· 353 374 nil, 354 375 func() (bool, string) { 355 376 mStatus.SetTitle("Backing up...") 356 - if ok, _ := doBackup(idx, mStatus, prof, true); !ok { 357 - return false, getProfileFailStatus(idx) 377 + if ok, _ := doBackup(key, mStatus, prof, true); !ok { 378 + return false, getProfileFailStatus(key) 358 379 } 359 380 return true, "" 360 381 }, 361 382 func() (bool, string) { 362 383 mStatus.SetTitle("Pruning repository...") 363 - if msg, err := runBackend(idx, prof, mStatus, forgetArgs(prof)...); err != nil { 364 - setProfileFailed(idx, msg) 384 + if msg, err := runBackend(key, prof, mStatus, forgetArgs(prof)...); err != nil { 385 + setProfileFailed(key, msg) 365 386 return false, msg 366 387 } 367 388 return true, "" 368 389 }, 369 390 func() (bool, string) { 370 391 mStatus.SetTitle("Checking repository...") 371 - if msg, err := runBackend(idx, prof, mStatus, checkArgs(prof)...); err != nil { 372 - setProfileFailed(idx, msg) 392 + if msg, err := runBackend(key, prof, mStatus, checkArgs(prof)...); err != nil { 393 + setProfileFailed(key, msg) 373 394 return false, msg 374 395 } 375 396 return true, "" ··· 377 398 ) 378 399 379 400 if failed { 380 - notifyError("Schedule", getProfileFailStatus(idx)) 401 + notifyError("Schedule", getProfileFailStatus(key)) 381 402 } else { 382 403 notifySuccess("Schedule") 383 404 } ··· 386 407 mStatus.SetTitle("Running post-hook...") 387 408 postEnv := hookEnv 388 409 if failed { 389 - postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) 410 + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key)) 390 411 } 391 412 if err := runHook(prof.PostHook, prof, postEnv...); err != nil { 392 - setProfileFailed(idx, "Post-hook failed") 413 + setProfileFailed(key, "Post-hook failed") 393 414 notifyError("Schedule", "Post-hook failed") 394 415 } 395 416 } ··· 429 450 return append(args, dir) 430 451 } 431 452 432 - func isProfileMounted(idx profileIndex) bool { 453 + func isProfileMounted(key stateKey) bool { 433 454 state.mu.Lock() 434 455 defer state.mu.Unlock() 435 - return state.mountCmds[idx] != nil 456 + return state.mountCmds[key] != nil 436 457 } 437 458 438 - func startMount(idx profileIndex, prof Profile, mMount *systray.MenuItem, onDone func()) { 459 + func startMount(key stateKey, prof Profile, mMount *systray.MenuItem, onDone func()) { 439 460 if !mountSupported(prof) { 440 461 notifyError("Mount", backendUnavailable(prof, "mount")) 441 462 onDone() ··· 443 464 } 444 465 dir, err := os.MkdirTemp("", "restray-mount-") 445 466 if err != nil { 467 + notifyError("Mount", err.Error()) 468 + onDone() 446 469 return 447 470 } 448 471 ··· 458 481 } 459 482 460 483 state.mu.Lock() 461 - state.mountCmds[idx] = cmd 484 + state.mountCmds[key] = cmd 462 485 state.mu.Unlock() 463 486 464 487 mMount.SetTitle("Unmount") ··· 494 517 } 495 518 496 519 state.mu.Lock() 497 - delete(state.mountCmds, idx) 498 - stopped := state.mountStopping[idx] 499 - delete(state.mountStopping, idx) 520 + delete(state.mountCmds, key) 521 + stopped := state.mountStopping[key] 522 + delete(state.mountStopping, key) 500 523 state.mu.Unlock() 501 524 os.Remove(dir) 502 525 ··· 507 530 onDone() 508 531 } 509 532 510 - func stopProfileMount(idx profileIndex) { 533 + func stopProfileMount(key stateKey) { 511 534 state.mu.Lock() 512 535 defer state.mu.Unlock() 513 - if cmd := state.mountCmds[idx]; cmd != nil && cmd.Process != nil { 514 - state.mountStopping[idx] = true 536 + if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil { 537 + state.mountStopping[key] = true 515 538 interruptProcess(cmd.Process) 516 539 } 517 540 } ··· 519 542 func stopAllMounts() { 520 543 state.mu.Lock() 521 544 defer state.mu.Unlock() 522 - for idx, cmd := range state.mountCmds { 545 + for key, cmd := range state.mountCmds { 523 546 if cmd != nil && cmd.Process != nil { 524 - state.mountStopping[idx] = true 547 + state.mountStopping[key] = true 525 548 interruptProcess(cmd.Process) 526 549 } 527 550 }
+26 -26
cmd/restray/state.go
··· 6 6 "time" 7 7 ) 8 8 9 - type profileIndex = int 9 + type stateKey string 10 10 11 11 type appState struct { 12 12 mu sync.Mutex 13 - busyProfiles map[profileIndex]*exec.Cmd 13 + busyProfiles map[stateKey]*exec.Cmd 14 14 onBusyChanged func(bool) 15 - mountCmds map[profileIndex]*exec.Cmd 16 - mountStopping map[profileIndex]bool 15 + mountCmds map[stateKey]*exec.Cmd 16 + mountStopping map[stateKey]bool 17 17 notifications string 18 - failStatus map[profileIndex]string 19 - lastBackup map[profileIndex]time.Time 18 + failStatus map[stateKey]string 19 + lastBackup map[stateKey]time.Time 20 20 } 21 21 22 22 var state = appState{ 23 - busyProfiles: make(map[profileIndex]*exec.Cmd), 24 - mountCmds: make(map[profileIndex]*exec.Cmd), 25 - mountStopping: make(map[profileIndex]bool), 26 - failStatus: make(map[profileIndex]string), 27 - lastBackup: make(map[profileIndex]time.Time), 23 + busyProfiles: make(map[stateKey]*exec.Cmd), 24 + mountCmds: make(map[stateKey]*exec.Cmd), 25 + mountStopping: make(map[stateKey]bool), 26 + failStatus: make(map[stateKey]string), 27 + lastBackup: make(map[stateKey]time.Time), 28 28 } 29 29 30 - func setLastBackup(idx profileIndex, t time.Time) { 30 + func setLastBackup(key stateKey, t time.Time) { 31 31 state.mu.Lock() 32 - state.lastBackup[idx] = t 32 + state.lastBackup[key] = t 33 33 state.mu.Unlock() 34 34 } 35 35 36 - func getLastBackup(idx profileIndex) time.Time { 36 + func getLastBackup(key stateKey) time.Time { 37 37 state.mu.Lock() 38 38 defer state.mu.Unlock() 39 - return state.lastBackup[idx] 39 + return state.lastBackup[key] 40 40 } 41 41 42 - func isProfileBusy(idx profileIndex) bool { 42 + func isProfileBusy(key stateKey) bool { 43 43 state.mu.Lock() 44 44 defer state.mu.Unlock() 45 - _, ok := state.busyProfiles[idx] 45 + _, ok := state.busyProfiles[key] 46 46 return ok 47 47 } 48 48 ··· 52 52 return len(state.busyProfiles) > 0 53 53 } 54 54 55 - func releaseProfile(idx profileIndex) { 55 + func releaseProfile(key stateKey) { 56 56 state.mu.Lock() 57 57 wasBusy := len(state.busyProfiles) > 0 58 - delete(state.busyProfiles, idx) 58 + delete(state.busyProfiles, key) 59 59 nowBusy := len(state.busyProfiles) > 0 60 60 cb := state.onBusyChanged 61 61 state.mu.Unlock() 62 62 finishBusyChange(wasBusy, nowBusy, cb) 63 63 } 64 64 65 - func acquireProfile(idx profileIndex) bool { 65 + func acquireProfile(key stateKey) bool { 66 66 state.mu.Lock() 67 - if _, busy := state.busyProfiles[idx]; busy { 67 + if _, busy := state.busyProfiles[key]; busy { 68 68 state.mu.Unlock() 69 69 return false 70 70 } 71 71 wasBusy := len(state.busyProfiles) > 0 72 - state.busyProfiles[idx] = nil 72 + state.busyProfiles[key] = nil 73 73 nowBusy := len(state.busyProfiles) > 0 74 74 cb := state.onBusyChanged 75 75 state.mu.Unlock() ··· 86 86 } 87 87 } 88 88 89 - func setProfileBusyCmd(idx profileIndex, cmd *exec.Cmd) { 89 + func setProfileBusyCmd(key stateKey, cmd *exec.Cmd) { 90 90 state.mu.Lock() 91 91 defer state.mu.Unlock() 92 - state.busyProfiles[idx] = cmd 92 + state.busyProfiles[key] = cmd 93 93 } 94 94 95 - func cancelProfile(idx profileIndex) { 95 + func cancelProfile(key stateKey) { 96 96 state.mu.Lock() 97 97 defer state.mu.Unlock() 98 - if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 98 + if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { 99 99 interruptProcess(cmd.Process) 100 100 } 101 101 }
+153 -123
cmd/restray/tray.go
··· 66 66 return activeProfile 67 67 } 68 68 69 - func setProfileFailed(idx profileIndex, status string) { 69 + func setProfileFailed(key stateKey, 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 profileIndex) string { 79 + func getProfileFailStatus(key stateKey) 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 { ··· 124 124 return false 125 125 } 126 126 127 + func startTrayBackendMonitor(selectedProfile func() Profile, applyConfig func(), item *systray.MenuItem, status prefixedMenuItem) { 128 + go func() { 129 + defer func() { 130 + if r := recover(); r != nil { 131 + log.Printf("backend update checker panicked: %v", r) 132 + } 133 + }() 134 + ticker := time.NewTicker(24 * time.Hour) 135 + defer ticker.Stop() 136 + for range ticker.C { 137 + prof := selectedProfile() 138 + backendPath, managed := findBackend(prof) 139 + if managed && !isAnyBusy() { 140 + cfg := loadConfig() 141 + checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), item, status, applyConfig) 142 + } 143 + } 144 + }() 145 + } 146 + 147 + func startTrayStatusMonitor(applyConfig func(), updateStatus, applyProfileUI func(Config)) { 148 + go func() { 149 + defer func() { 150 + if r := recover(); r != nil { 151 + log.Printf("status monitor panicked: %v", r) 152 + } 153 + }() 154 + prev := onBatteryPower() 155 + lastWall := time.Now().Round(0) 156 + ticker := time.NewTicker(time.Minute) 157 + defer ticker.Stop() 158 + for range ticker.C { 159 + now := time.Now().Round(0) 160 + drift := now.Sub(lastWall) - time.Minute 161 + lastWall = now 162 + 163 + if drift.Abs() > 30*time.Second && !isAnyBusy() { 164 + log.Print("clock jump detected, rebuilding scheduler") 165 + applyConfig() 166 + continue 167 + } 168 + 169 + cur := onBatteryPower() 170 + if cur != prev { 171 + prev = cur 172 + if !isAnyBusy() { 173 + cfg := loadConfig() 174 + updateStatus(cfg) 175 + applyProfileUI(cfg) 176 + continue 177 + } 178 + } 179 + 180 + cfg := loadConfig() 181 + if !isAnyBusy() && unreachableRecovered(cfg) { 182 + applyConfig() 183 + continue 184 + } 185 + updateStatus(cfg) 186 + } 187 + }() 188 + } 189 + 190 + func startProfileSelectionHandlers(items [maxProfiles]*systray.MenuItem, selectProfile func(int)) { 191 + for i := range items { 192 + idx := i 193 + go func() { 194 + for range items[idx].ClickedCh { 195 + selectProfile(idx) 196 + } 197 + }() 198 + } 199 + } 200 + 201 + func startAppUpdateChecker(item *systray.MenuItem) { 202 + go func() { 203 + check := func() { 204 + latest, err := latestVersion() 205 + if err != nil { 206 + log.Printf("update check failed: %v", err) 207 + return 208 + } 209 + if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { 210 + latestKnownVersion.Store(&latest) 211 + item.SetTitle("Update to v" + latest) 212 + } 213 + } 214 + check() 215 + ticker := time.NewTicker(6 * time.Hour) 216 + defer ticker.Stop() 217 + for range ticker.C { 218 + check() 219 + } 220 + }() 221 + } 222 + 127 223 func onReady() { 128 224 firstLaunch := !fileExists(configPath()) 129 225 applyIconMode(loadConfig().GUI.Icon) ··· 302 398 paused := !prof.Schedule.OnBattery && onBatteryPower() 303 399 if prof.Schedule.Cron == "" { 304 400 if cfg.GUI.ScheduleDisplay == "last" { 305 - last := formatLastBackup(getLastBackup(i)) 401 + last := formatLastBackup(getLastBackup(prof.profileKey())) 306 402 return prefix + "Unscheduled, last " + last 307 403 } 308 404 return prefix + "Unscheduled" ··· 324 420 } 325 421 return prefix + prof.Schedule.Cron 326 422 case "last": 327 - last := formatLastBackup(getLastBackup(i)) 423 + last := formatLastBackup(getLastBackup(prof.profileKey())) 328 424 if next != "" { 329 425 return prefix + next + ", last " + last 330 426 } ··· 342 438 updateAllStatusItems := func(cfg Config) { 343 439 mGlobalStatus.Hide() 344 440 for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { 345 - if !isProfileBusy(i) { 346 - if failMsg := getProfileFailStatus(i); failMsg != "" { 441 + if !isProfileBusy(cfg.Profiles[i].profileKey()) { 442 + if failMsg := getProfileFailStatus(cfg.Profiles[i].profileKey()); failMsg != "" { 347 443 statusItem(i).SetTitle(failMsg) 348 444 } else { 349 445 mStatusItems[i].SetTitle(profileStatusText(cfg, i)) ··· 381 477 mPostHook.Disable() 382 478 383 479 mMount.Show() 384 - if isProfileMounted(idx) { 480 + if isProfileMounted(prof.profileKey()) { 385 481 mMount.SetTitle("Unmount") 386 482 mMount.Enable() 387 483 } else if mountSupported(prof) { ··· 392 488 mMount.Disable() 393 489 } 394 490 395 - if isProfileBusy(idx) { 491 + if isProfileBusy(prof.profileKey()) { 396 492 mCancel.Show() 397 493 mRepo.Enable() 398 494 mBackup.Disable() ··· 437 533 } 438 534 ps.errMsg = prof.configError() 439 535 if ps.errMsg == "" { 440 - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() 536 + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() 441 537 var rs repoResult 442 538 if wantsLast { 443 539 var last time.Time 444 540 rs, last = repoStatusAndLastBackup(prof) 445 541 if !last.IsZero() { 446 - setLastBackup(i, last) 542 + setLastBackup(prof.profileKey(), last) 447 543 } 448 544 } else { 449 545 rs = repoStatus(prof) ··· 471 567 profileStates[idx] = ps 472 568 } 473 569 profileMu.Unlock() 474 - if !isProfileBusy(idx) { 475 - if failMsg := getProfileFailStatus(idx); failMsg != "" { 570 + if !isProfileBusy(prof.profileKey()) { 571 + if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" { 476 572 statusItem(idx).SetTitle(failMsg) 477 573 } else { 478 574 mStatusItems[idx].SetTitle(profileStatusText(cfg, idx)) ··· 607 703 defer wg.Done() 608 704 ps := probeProfileState(cfg, i, prof) 609 705 610 - if prof.Schedule.Cron != "" { 706 + if prof.Schedule.Cron != "" && i < maxProfiles { 611 707 if errMsg := prof.scheduleError(); errMsg != "" { 612 708 log.Printf("[%s] skipping schedule: %s", prof.displayName(), errMsg) 613 709 } else { 710 + key := prof.profileKey() 614 711 name := prof.displayName() 615 712 eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 616 713 current := loadConfig() 617 - prof, err := resolveProfileAt(current, i, name) 714 + idx, prof, err := resolveProfileIndex(current, key) 618 715 if err != nil { 619 716 log.Printf("[%s] skipping scheduled run: %v", name, err) 620 717 return ··· 629 726 log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 630 727 } 631 728 }() 632 - runScheduled(i, statusItem(i), prof, func() { finishProfile(i) }) 729 + runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 633 730 }() 634 731 }) 635 732 if err != nil { ··· 681 778 } 682 779 watchConfig(applyConfig) 683 780 684 - go func() { 685 - defer func() { 686 - if r := recover(); r != nil { 687 - log.Printf("update checker panicked: %v", r) 688 - } 689 - }() 690 - for range time.NewTicker(24 * time.Hour).C { 691 - prof := selectedProfile() 692 - backendPath, managed := findBackend(prof) 693 - if managed && !isAnyBusy() { 694 - cfg := loadConfig() 695 - checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), mUpdate, mGlobalStatus, applyConfig) 696 - } 697 - } 698 - }() 699 - 700 - go func() { 701 - defer func() { 702 - if r := recover(); r != nil { 703 - log.Printf("monitor loop panicked: %v", r) 704 - } 705 - }() 706 - prev := onBatteryPower() 707 - lastWall := time.Now().Round(0) 708 - for range time.NewTicker(60 * time.Second).C { 709 - now := time.Now().Round(0) 710 - drift := now.Sub(lastWall) - 60*time.Second 711 - lastWall = now 712 - 713 - if drift.Abs() > 30*time.Second && !isAnyBusy() { 714 - log.Print("clock jump detected, rebuilding scheduler") 715 - applyConfig() 716 - continue 717 - } 718 - 719 - cur := onBatteryPower() 720 - if cur != prev { 721 - prev = cur 722 - if !isAnyBusy() { 723 - cfg := loadConfig() 724 - updateAllStatusItems(cfg) 725 - applyProfileUI(cfg) 726 - continue 727 - } 728 - } 729 - 730 - if !isAnyBusy() && unreachableRecovered(loadConfig()) { 731 - applyConfig() 732 - continue 733 - } 734 - 735 - updateAllStatusItems(loadConfig()) 736 - } 737 - }() 781 + startTrayBackendMonitor(selectedProfile, applyConfig, mUpdate, mGlobalStatus) 782 + startTrayStatusMonitor(applyConfig, updateAllStatusItems, applyProfileUI) 738 783 739 784 repoOp := func(status string, args ...string) { 740 785 idx := getActiveProfile() ··· 744 789 } 745 790 ms := statusItem(idx) 746 791 go func() { 747 - if !acquireProfile(idx) { 792 + if !acquireProfile(prof.profileKey()) { 748 793 return 749 794 } 750 - setProfileFailed(idx, "") 795 + setProfileFailed(prof.profileKey(), "") 751 796 defer finishProfile(idx) 752 - defer releaseProfile(idx) 797 + defer releaseProfile(prof.profileKey()) 753 798 ms.SetTitle(status) 754 - if msg, err := runBackend(idx, prof, ms, args...); err != nil { 755 - setProfileFailed(idx, msg) 799 + if msg, err := runBackend(prof.profileKey(), prof, ms, args...); err != nil { 800 + setProfileFailed(prof.profileKey(), msg) 756 801 notifyError(args[0], msg) 757 802 } else { 758 803 notifySuccess(args[0]) ··· 767 812 idx := getActiveProfile() 768 813 prof := selectedProfile() 769 814 if idx < maxProfiles { 770 - go runScheduled(idx, statusItem(idx), prof, func() { finishProfile(idx) }) 815 + go runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 771 816 } 772 817 case <-mBackup.ClickedCh: 773 818 idx := getActiveProfile() 774 819 prof := selectedProfile() 775 820 if idx < maxProfiles { 776 - go runBackup(idx, statusItem(idx), prof, func() { finishProfile(idx) }) 821 + go runBackup(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 777 822 } 778 823 case <-mCancel.ClickedCh: 779 - idx := getActiveProfile() 780 - cancelProfile(idx) 824 + cancelProfile(selectedProfile().profileKey()) 781 825 case <-mPrune.ClickedCh: 782 826 prof := selectedProfile() 783 827 if len(prof.Prune.Args) > 0 { ··· 794 838 case <-mMount.ClickedCh: 795 839 idx := getActiveProfile() 796 840 prof := selectedProfile() 797 - if isProfileMounted(idx) { 798 - stopProfileMount(idx) 841 + if isProfileMounted(prof.profileKey()) { 842 + stopProfileMount(prof.profileKey()) 799 843 } else { 800 - go startMount(idx, prof, mMount, func() { finishProfile(idx) }) 844 + go startMount(prof.profileKey(), prof, mMount, func() { finishProfile(idx) }) 801 845 } 802 846 case <-mConsole.ClickedCh: 803 847 cfg := loadConfig() ··· 841 885 go doDownload() 842 886 } 843 887 case <-mSettings.ClickedCh: 844 - ensureConfigFile() 888 + if err := ensureConfigFile(); err != nil { 889 + log.Printf("config: %v", err) 890 + continue 891 + } 845 892 openInEditor(configPath()) 846 893 case <-mWebEditor.ClickedCh: 847 - ensureConfigFile() 894 + if err := ensureConfigFile(); err != nil { 895 + log.Printf("config: %v", err) 896 + continue 897 + } 848 898 url, err := ensureWebEditor() 849 899 if err != nil { 850 900 log.Printf("webeditor: %v", err) ··· 853 903 openFile(url) 854 904 case <-mEnv.ClickedCh: 855 905 prof := selectedProfile() 856 - ensureEnvFile(prof.EnvFile) 906 + if err := ensureEnvFile(prof.EnvFile); err != nil { 907 + log.Printf("env: %v", err) 908 + continue 909 + } 857 910 openInEditor(prof.EnvFile) 858 911 case <-mFDA.ClickedCh: 859 912 openFullDiskAccessSettings() ··· 892 945 }() 893 946 894 947 if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { 895 - go func() { 896 - check := func() { 897 - latest, err := latestVersion() 898 - if err != nil { 899 - log.Printf("update check failed: %v", err) 900 - return 901 - } 902 - if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { 903 - latestKnownVersion.Store(&latest) 904 - mAbout.SetTitle("Update to v" + latest) 905 - } 906 - } 907 - check() 908 - t := time.NewTicker(6 * time.Hour) 909 - defer t.Stop() 910 - for range t.C { 911 - check() 912 - } 913 - }() 948 + startAppUpdateChecker(mAbout) 914 949 } 915 950 916 - for i := 0; i < maxProfiles; i++ { 917 - idx := i 918 - go func() { 919 - for range profileItems[idx].ClickedCh { 920 - cfg := loadConfig() 921 - if idx >= len(cfg.Profiles) { 922 - continue 923 - } 924 - profileMu.Lock() 925 - activeProfile = idx 926 - profileMu.Unlock() 927 - markActiveProfile(idx, len(cfg.Profiles)) 928 - setProfileTitle(cfg, idx) 929 - applyProfileUI(cfg) 930 - } 931 - }() 932 - } 951 + startProfileSelectionHandlers(profileItems, func(idx int) { 952 + cfg := loadConfig() 953 + if idx >= len(cfg.Profiles) { 954 + return 955 + } 956 + profileMu.Lock() 957 + activeProfile = idx 958 + profileMu.Unlock() 959 + markActiveProfile(idx, len(cfg.Profiles)) 960 + setProfileTitle(cfg, idx) 961 + applyProfileUI(cfg) 962 + }) 933 963 } 934 964 935 965 func formatNextFor(sched *cron.Cron, ids []cron.EntryID) string {
+1 -1
flake.nix
··· 68 68 ''; 69 69 70 70 meta = { 71 - description = "Restic and Rustic backup scheduler system tray app"; 71 + description = "A powerful but friendly backup program powered by restic"; 72 72 license = pkgs.lib.licenses.gpl3Only; 73 73 platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; 74 74 mainProgram = "restray";