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: simplify tray scheduling and profile state

- replace tray setup with controller
- track profiles by stable keys
- centralize automatic schedule behavior
- separate repository and schedule validation so maintenance-only
profiles work without backup paths
- add some more tests

intergrav (Jul 17, 2026, 11:09 AM EDT) 0a688ee6 d235a5c2

+1358 -843
+2 -2
.gitignore
··· 3 3 bin/ 4 4 dist/ 5 5 result 6 - /Restray 7 - /Restray.exe 6 + /restray 7 + /restray.exe 8 8 /cmd/restray/restray_windows_*.syso
+21 -28
cmd/restray/cli.go
··· 160 160 } 161 161 162 162 func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { 163 - stdoutPipe, _ := cmd.StdoutPipe() 164 - stderrPipe, _ := cmd.StderrPipe() 163 + stdoutPipe, err := cmd.StdoutPipe() 164 + if err != nil { 165 + return err 166 + } 167 + stderrPipe, err := cmd.StderrPipe() 168 + if err != nil { 169 + return err 170 + } 165 171 if err := cmd.Start(); err != nil { 166 172 return err 167 173 } ··· 171 177 go func(pipe io.Reader) { 172 178 defer wg.Done() 173 179 scanner := bufio.NewScanner(pipe) 180 + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) 174 181 for scanner.Scan() { 175 182 logPrefixedLine(prof.displayName(), scanner.Text()) 176 183 } 184 + if err := scanner.Err(); err != nil { 185 + log.Printf("[%s] reading command output: %v", prof.displayName(), err) 186 + _, _ = io.Copy(io.Discard, pipe) 187 + } 177 188 }(pipe) 178 189 } 179 - err := cmd.Wait() 190 + err = cmd.Wait() 180 191 wg.Wait() 181 192 return err 182 193 } ··· 281 292 } 282 293 283 294 func cliSchedule(prof Profile) error { 284 - if errMsg := prof.profileError(); errMsg != "" { 295 + if errMsg := prof.scheduleError(); errMsg != "" { 285 296 return cli.Exit("error: "+errMsg, 1) 286 297 } 287 298 p, _ := findBackend(prof) ··· 368 379 } 369 380 key := prof.profileKey() 370 381 name := prof.displayName() 371 - if errMsg := prof.scheduleConfigError(); errMsg != "" { 382 + if errMsg := prof.scheduleDefinitionError(); errMsg != "" { 372 383 log.Printf("[%s] skipping schedule: %s", name, errMsg) 373 384 continue 374 385 } 375 386 _, err := sched.AddFunc(prof.Schedule.Cron, func() { 376 - current := loadConfig() 377 - _, prof, err := resolveProfileIndex(current, key) 378 - if err != nil { 379 - log.Printf("[%s] skipping scheduled run: %v", name, err) 380 - return 381 - } 382 - if skipForBattery(prof) { 383 - log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) 384 - return 385 - } 386 - if !acquireProfile(key) { 387 - log.Printf("[%s] skipping scheduled run: profile already busy", prof.displayName()) 388 - return 389 - } 390 - defer releaseProfile(key) 391 - defer func() { 392 - if r := recover(); r != nil { 393 - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 387 + runScheduleCallback(key, nil, func(_ Config, _ int, prof Profile) { 388 + log.Printf("[%s] starting scheduled run", prof.displayName()) 389 + if err := cliSchedule(prof); err != nil { 390 + log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err) 394 391 } 395 - }() 396 - log.Printf("[%s] starting scheduled run", prof.displayName()) 397 - if err := cliSchedule(prof); err != nil { 398 - log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err) 399 - } 392 + }) 400 393 }) 401 394 if err != nil { 402 395 log.Printf("[%s] skipping schedule: invalid cron expression %q: %v", name, prof.Schedule.Cron, err)
+28 -30
cmd/restray/config.go
··· 41 41 ArgsScheduled []string `toml:"args_scheduled"` 42 42 } 43 43 44 - type Prune struct { 45 - Args []string `toml:"args"` 46 - } 47 - 48 - type Check struct { 49 - Args []string `toml:"args"` 50 - } 51 - 52 - type Mount struct { 44 + type OperationOptions struct { 53 45 Args []string `toml:"args"` 54 46 } 55 47 56 48 type Profile struct { 57 - Name string `toml:"name"` 58 - Backend string `toml:"backend"` 59 - EnvFile string `toml:"env_file"` 60 - RcloneConfigFile string `toml:"rclone_config_file"` 61 - RetryLock string `toml:"retry_lock"` 62 - PreHook string `toml:"pre_hook"` 63 - PostHook string `toml:"post_hook"` 64 - Schedule Schedule `toml:"schedule"` 65 - Backup Backup `toml:"backup"` 66 - Prune Prune `toml:"prune"` 67 - Check Check `toml:"check"` 68 - Mount Mount `toml:"mount"` 49 + Name string `toml:"name"` 50 + Backend string `toml:"backend"` 51 + EnvFile string `toml:"env_file"` 52 + RcloneConfigFile string `toml:"rclone_config_file"` 53 + RetryLock string `toml:"retry_lock"` 54 + PreHook string `toml:"pre_hook"` 55 + PostHook string `toml:"post_hook"` 56 + Schedule Schedule `toml:"schedule"` 57 + Backup Backup `toml:"backup"` 58 + Prune OperationOptions `toml:"prune"` 59 + Check OperationOptions `toml:"check"` 60 + Mount OperationOptions `toml:"mount"` 69 61 } 70 62 71 63 func (prof Profile) profileKey() stateKey { ··· 133 125 return env, nil 134 126 } 135 127 136 - func (prof Profile) scheduleConfigError() string { 128 + func (prof Profile) baseConfigError() string { 137 129 if prof.backend() == "" { 138 130 return "Unsupported backend: " + prof.Backend 139 131 } 140 - if prof.Schedule.BackupEnabled() && len(prof.Backup.Paths) == 0 { 141 - return "No paths configured" 142 - } 143 132 return "" 144 133 } 145 134 146 - func (prof Profile) environmentError() string { 135 + func (prof Profile) repositoryError() string { 136 + if err := prof.baseConfigError(); err != "" { 137 + return err 138 + } 147 139 vars, err := parseEnvFile(prof.EnvFile) 148 140 if err != nil { 149 141 return "Env file not available" ··· 151 143 return backendEnvError(prof.backend(), vars) 152 144 } 153 145 154 - func (prof Profile) profileError() string { 155 - if err := prof.scheduleConfigError(); err != "" { 146 + func (prof Profile) scheduleError() string { 147 + if err := prof.scheduleDefinitionError(); err != "" { 156 148 return err 157 149 } 158 - if len(prof.Backup.Paths) == 0 { 150 + return prof.repositoryError() 151 + } 152 + func (prof Profile) scheduleDefinitionError() string { 153 + if err := prof.baseConfigError(); err != "" { 154 + return err 155 + } 156 + if prof.Schedule.BackupEnabled() && len(prof.Backup.Paths) == 0 { 159 157 return "No paths configured" 160 158 } 161 - return prof.environmentError() 159 + return "" 162 160 } 163 161 164 162 func (prof Profile) displayName() string {
+78 -1
cmd/restray/config_test.go
··· 23 23 } 24 24 } 25 25 26 + func TestProfileRepositoryError(t *testing.T) { 27 + tests := []struct { 28 + name string 29 + backend string 30 + env string 31 + missingEnv bool 32 + want string 33 + }{ 34 + {name: "valid restic environment", backend: "restic", env: "RESTIC_REPOSITORY=/repo\nRESTIC_PASSWORD=secret\n"}, 35 + {name: "valid rustic environment", backend: "rustic", env: "RUSTIC_REPOSITORY=/repo\n"}, 36 + {name: "missing env file", backend: "restic", missingEnv: true, want: "Env file not available"}, 37 + 38 + {name: "missing repository", backend: "restic", env: "RESTIC_PASSWORD=secret\n", want: "RESTIC_REPOSITORY not set in env file"}, 39 + {name: "missing restic password", backend: "restic", env: "RESTIC_REPOSITORY=/repo\n", want: "No password set in env file"}, 40 + {name: "unsupported backend", backend: "borg", env: "BORG_REPOSITORY=/repo\n", want: "Unsupported backend: borg"}, 41 + } 42 + 43 + for _, tt := range tests { 44 + t.Run(tt.name, func(t *testing.T) { 45 + envFile := filepath.Join(t.TempDir(), "profile.env") 46 + if !tt.missingEnv { 47 + if err := os.WriteFile(envFile, []byte(tt.env), 0600); err != nil { 48 + t.Fatal(err) 49 + } 50 + } 51 + prof := Profile{Backend: tt.backend, EnvFile: envFile} 52 + if got := prof.repositoryError(); got != tt.want { 53 + t.Fatalf("repositoryError() = %q, want %q", got, tt.want) 54 + } 55 + }) 56 + } 57 + } 58 + 59 + func TestProfileScheduleError(t *testing.T) { 60 + enabled := true 61 + disabled := false 62 + tests := []struct { 63 + name string 64 + backup *bool 65 + paths []string 66 + prune bool 67 + want string 68 + }{ 69 + {name: "backup enabled by default without paths", want: "No paths configured"}, 70 + {name: "backup explicitly enabled without paths", backup: &enabled, want: "No paths configured"}, 71 + {name: "backup enabled with paths", paths: []string{"/data"}}, 72 + {name: "prune only without paths", backup: &disabled, prune: true}, 73 + } 74 + 75 + for _, tt := range tests { 76 + t.Run(tt.name, func(t *testing.T) { 77 + envFile := filepath.Join(t.TempDir(), "profile.env") 78 + if err := os.WriteFile(envFile, []byte("RESTIC_REPOSITORY=/repo\nRESTIC_PASSWORD=secret\n"), 0600); err != nil { 79 + t.Fatal(err) 80 + } 81 + prof := Profile{ 82 + Backend: "restic", 83 + EnvFile: envFile, 84 + Schedule: Schedule{Backup: tt.backup, Prune: tt.prune}, 85 + Backup: Backup{Paths: tt.paths}, 86 + } 87 + if got := prof.scheduleError(); got != tt.want { 88 + t.Fatalf("scheduleError() = %q, want %q", got, tt.want) 89 + } 90 + }) 91 + } 92 + } 93 + 26 94 func TestLoadConfig(t *testing.T) { 27 95 dir := t.TempDir() 28 96 old := configDirOverride 29 97 configDirOverride = dir 30 98 t.Cleanup(func() { configDirOverride = old }) 31 99 32 - contents := "[[profiles]]\nname = 'work'\n[profiles.backup]\npaths = ['/home/me']\n" 100 + contents := "[[profiles]]\nname = 'work'\n[profiles.backup]\npaths = ['/home/me']\n[profiles.prune]\nargs = ['--keep-last', '2']\n[profiles.check]\nargs = ['--read-data']\n[profiles.mount]\nargs = ['--allow-other']\n" 33 101 if err := os.WriteFile(configPath(), []byte(contents), 0600); err != nil { 34 102 t.Fatal(err) 35 103 } ··· 43 111 } 44 112 if !reflect.DeepEqual(p.Backup.Args, []string{"--exclude-caches", "--exclude", "*.tmp"}) { 45 113 t.Fatalf("backup defaults = %#v", p.Backup.Args) 114 + } 115 + if !reflect.DeepEqual(p.Prune.Args, []string{"--keep-last", "2"}) { 116 + t.Fatalf("prune options = %#v", p.Prune.Args) 117 + } 118 + if !reflect.DeepEqual(p.Check.Args, []string{"--read-data"}) { 119 + t.Fatalf("check options = %#v", p.Check.Args) 120 + } 121 + if !reflect.DeepEqual(p.Mount.Args, []string{"--allow-other"}) { 122 + t.Fatalf("mount options = %#v", p.Mount.Args) 46 123 } 47 124 48 125 if err := os.WriteFile(configPath(), []byte("[[profiles]]\nname = 'same'\n[[profiles]]\nname = 'same'\n"), 0600); err != nil {
+69 -39
cmd/restray/operations.go
··· 11 11 "path/filepath" 12 12 "runtime" 13 13 "strings" 14 - "sync" 15 14 "time" 16 15 17 16 "fyne.io/systray" ··· 19 18 "github.com/gen2brain/beeep" 20 19 ) 21 20 21 + type backupStatus struct { 22 + Type string `json:"message_type"` 23 + Percent float64 `json:"percent_done"` 24 + Total uint64 `json:"total_bytes"` 25 + Bytes uint64 `json:"bytes_done"` 26 + TotalFiles uint64 `json:"total_files"` 27 + FilesDone uint64 `json:"files_done"` 28 + } 29 + 30 + func parseBackupStatus(line string) (backupStatus, bool) { 31 + var status backupStatus 32 + if json.Unmarshal([]byte(line), &status) != nil || status.Type != "status" { 33 + return backupStatus{}, false 34 + } 35 + return status, true 36 + } 37 + 22 38 type lastLineLogger struct { 23 - mu sync.Mutex 24 39 last string 40 + done chan struct{} 25 41 } 26 42 27 - func (s *lastLineLogger) lastLine() string { 28 - s.mu.Lock() 29 - defer s.mu.Unlock() 43 + func (s *lastLineLogger) wait() string { 44 + <-s.done 30 45 return s.last 31 46 } 32 47 ··· 43 58 } 44 59 45 60 func streamLogLines(pipe io.Reader, name string, onLine func(string)) *lastLineLogger { 46 - sl := &lastLineLogger{} 61 + sl := &lastLineLogger{done: make(chan struct{})} 47 62 go func() { 63 + defer close(sl.done) 48 64 scanner := bufio.NewScanner(pipe) 65 + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) 49 66 for scanner.Scan() { 50 67 line := scanner.Text() 51 68 logPrefixedLine(name, line) 52 - sl.mu.Lock() 53 69 sl.last = line 54 - sl.mu.Unlock() 55 70 if onLine != nil { 56 71 onLine(line) 57 72 } 58 73 } 74 + if err := scanner.Err(); err != nil { 75 + log.Printf("[%s] reading command output: %v", name, err) 76 + _, _ = io.Copy(io.Discard, pipe) 77 + } 59 78 }() 60 79 return sl 61 80 } ··· 72 91 cmd := backendCmd(prof, args...) 73 92 var stdout strings.Builder 74 93 cmd.Stdout = &stdout 75 - stderrPipe, _ := cmd.StderrPipe() 94 + stderrPipe, err := cmd.StderrPipe() 95 + if err != nil { 96 + return err.Error(), err 97 + } 76 98 setProfileBusyCmd(key, cmd) 77 99 78 100 if err := cmd.Start(); err != nil { ··· 81 103 } 82 104 83 105 sl := streamStderr(stderrPipe, prof, mStatus) 84 - err := cmd.Wait() 106 + err = cmd.Wait() 107 + lastStderr := sl.wait() 85 108 if out := stdout.String(); out != "" { 86 109 logPrefixedOutput(prof.displayName(), out) 87 110 } ··· 89 112 log.Printf("[%s] done (%s): %s", prof.displayName(), prof.backendName(), args[0]) 90 113 return "", nil 91 114 } 92 - msg, code := classifyBackendError(prof, err, sl.lastLine()) 115 + msg, code := classifyBackendError(prof, err, lastStderr) 93 116 if msg != "" { 94 117 mStatus.SetTitle(msg) 95 118 } ··· 129 152 130 153 func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int, error) { 131 154 cmd := backendCmd(prof, args...) 132 - stdoutPipe, _ := cmd.StdoutPipe() 133 - stderrPipe, _ := cmd.StderrPipe() 155 + if prof.backend() == BackendRestic { 156 + cmd.Env = setEnv(cmd.Env, "RESTIC_PROGRESS_FPS", "4") 157 + } 158 + stdoutPipe, err := cmd.StdoutPipe() 159 + if err != nil { 160 + setProfileFailed(key, err.Error()) 161 + return false, -1, err 162 + } 163 + stderrPipe, err := cmd.StderrPipe() 164 + if err != nil { 165 + setProfileFailed(key, err.Error()) 166 + return false, -1, err 167 + } 134 168 setProfileBusyCmd(key, cmd) 135 169 136 170 if err := cmd.Start(); err != nil { ··· 146 180 for scanner.Scan() { 147 181 line := scanner.Text() 148 182 149 - var msg struct { 150 - Type string `json:"message_type"` 151 - Percent float64 `json:"percent_done"` 152 - Total uint64 `json:"total_bytes"` 153 - Bytes uint64 `json:"bytes_done"` 154 - TotalFiles uint64 `json:"total_files"` 155 - FilesDone uint64 `json:"files_done"` 156 - } 157 - if json.Unmarshal([]byte(line), &msg) != nil || msg.Type == "" { 183 + status, ok := parseBackupStatus(line) 184 + if !ok { 158 185 logPrefixedLine(prof.displayName(), line) 159 186 continue 160 187 } 161 - if msg.Type == "status" { 162 - switch { 163 - case msg.Total > 0: 164 - mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s", 165 - int(msg.Percent*100), humanize.Bytes(msg.Bytes), humanize.Bytes(msg.Total))) 166 - case msg.TotalFiles > 0: 167 - mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files", 168 - msg.FilesDone, msg.TotalFiles)) 169 - default: 170 - mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", msg.FilesDone)) 171 - } 172 - } else { 173 - logPrefixedLine(prof.displayName(), line) 188 + switch { 189 + case status.Total > 0: 190 + mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s", 191 + int(status.Percent*100), humanize.Bytes(status.Bytes), humanize.Bytes(status.Total))) 192 + case status.TotalFiles > 0: 193 + mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files", 194 + status.FilesDone, status.TotalFiles)) 195 + default: 196 + mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", status.FilesDone)) 174 197 } 175 198 } 176 199 if err := scanner.Err(); err != nil { 177 200 log.Printf("[%s] reading backup output: %v", prof.displayName(), err) 201 + _, _ = io.Copy(io.Discard, stdoutPipe) 178 202 } 179 203 180 - if err := cmd.Wait(); err != nil { 181 - msg, code := classifyBackendError(prof, err, sl.lastLine()) 204 + err = cmd.Wait() 205 + lastStderr := sl.wait() 206 + if err != nil { 207 + msg, code := classifyBackendError(prof, err, lastStderr) 182 208 log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) 183 209 if prof.backend() == BackendRestic && code == 3 { 184 210 log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) ··· 351 377 if !acquireProfile(key) { 352 378 return 353 379 } 354 - setProfileFailed(key, "") 355 380 defer onDone() 356 381 defer releaseProfile(key) 357 - if errMsg := prof.profileError(); errMsg != "" { 382 + runScheduledAcquired(key, mStatus, prof) 383 + } 384 + 385 + func runScheduledAcquired(key stateKey, mStatus prefixedMenuItem, prof Profile) { 386 + setProfileFailed(key, "") 387 + if errMsg := prof.scheduleError(); errMsg != "" { 358 388 mStatus.SetTitle(errMsg) 359 389 setProfileFailed(key, errMsg) 360 390 notifyError("Schedule", errMsg)
+15 -1
cmd/restray/operations_test.go
··· 5 5 "testing" 6 6 ) 7 7 8 + func TestParseBackupStatus(t *testing.T) { 9 + line := `{"message_type":"status","percent_done":0.5,"total_bytes":1000,"bytes_done":500,"total_files":10,"files_done":5}` 10 + want := backupStatus{Type: "status", Percent: 0.5, Total: 1000, Bytes: 500, TotalFiles: 10, FilesDone: 5} 11 + if got, ok := parseBackupStatus(line); !ok || !reflect.DeepEqual(got, want) { 12 + t.Fatalf("parseBackupStatus() = %#v, %v; want %#v, true", got, ok, want) 13 + } 14 + if _, ok := parseBackupStatus(`{"message_type":"summary"}`); ok { 15 + t.Fatal("expected summary message to be ignored") 16 + } 17 + if _, ok := parseBackupStatus("not json"); ok { 18 + t.Fatal("expected malformed status to be ignored") 19 + } 20 + } 21 + 8 22 func TestBackupArgs(t *testing.T) { 9 23 prof := Profile{ 10 24 Backend: "restic", ··· 28 42 } 29 43 30 44 func TestRunScheduledOperations(t *testing.T) { 31 - prof := Profile{Schedule: Schedule{Prune: true, Check: true}, Prune: Prune{Args: []string{"--keep-last", "4"}}} 45 + prof := Profile{Schedule: Schedule{Prune: true, Check: true}, Prune: OperationOptions{Args: []string{"--keep-last", "4"}}} 32 46 var ran []string 33 47 failed, msg := runScheduledOperations(prof, nil, 34 48 func() (bool, string) { ran = append(ran, "backup"); return false, "backup failed" },
+68
cmd/restray/schedule.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "strings" 6 + 7 + "github.com/distatus/battery" 8 + hcron "github.com/lnquy/cron" 9 + ) 10 + 11 + var cronDescriptor, _ = hcron.NewDescriptor() 12 + 13 + func describeCron(expr string) string { 14 + desc, err := cronDescriptor.ToDescription(expr, hcron.Locale_en) 15 + if err != nil { 16 + return expr 17 + } 18 + desc = strings.TrimSpace(desc) 19 + if len(desc) > 0 { 20 + desc = strings.ToLower(desc[:1]) + desc[1:] 21 + } 22 + return desc 23 + } 24 + 25 + func onBatteryPower() bool { 26 + b, err := battery.Get(0) 27 + if _, ok := err.(battery.ErrFatal); ok { 28 + return false 29 + } 30 + if b == nil { 31 + return false 32 + } 33 + return b.State.Raw == battery.Discharging 34 + } 35 + 36 + func schedulePausedForBattery(prof Profile) bool { 37 + return !prof.Schedule.OnBattery && onBatteryPower() 38 + } 39 + 40 + func runScheduleCallback(key stateKey, onDone func(), run func(Config, int, Profile)) { 41 + name := string(key) 42 + defer func() { 43 + if r := recover(); r != nil { 44 + log.Printf("[%s] scheduled run panicked: %v", name, r) 45 + } 46 + }() 47 + 48 + cfg := loadConfig() 49 + idx, prof, err := resolveProfileIndex(cfg, key) 50 + if err != nil { 51 + log.Printf("[%s] skipping scheduled run: %v", name, err) 52 + return 53 + } 54 + name = prof.displayName() 55 + if schedulePausedForBattery(prof) { 56 + log.Printf("[%s] skipping scheduled run: on battery power", name) 57 + return 58 + } 59 + if !acquireProfile(key) { 60 + log.Printf("[%s] skipping scheduled run: profile already busy", name) 61 + return 62 + } 63 + if onDone != nil { 64 + defer onDone() 65 + } 66 + defer releaseProfile(key) 67 + run(cfg, idx, prof) 68 + }
+120
cmd/restray/schedule_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "testing" 6 + ) 7 + 8 + func writeScheduleConfig(t *testing.T, contents string) { 9 + t.Helper() 10 + oldConfigDir := configDirOverride 11 + configDirOverride = t.TempDir() 12 + t.Cleanup(func() { configDirOverride = oldConfigDir }) 13 + if err := os.WriteFile(configPath(), []byte(contents), 0600); err != nil { 14 + t.Fatal(err) 15 + } 16 + } 17 + 18 + func setupScheduleCallbackTest(t *testing.T) stateKey { 19 + t.Helper() 20 + writeScheduleConfig(t, "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\non_battery = true\nbackup = false\n") 21 + resetBusyState(t) 22 + return stateKey("scheduled") 23 + } 24 + 25 + func TestRunScheduleCallback(t *testing.T) { 26 + key := setupScheduleCallbackTest(t) 27 + var ran bool 28 + doneAfterRelease := false 29 + runScheduleCallback(key, func() { 30 + doneAfterRelease = !isProfileBusy(key) 31 + }, func(_ Config, _ int, prof Profile) { 32 + ran = prof.profileKey() == key 33 + if !isProfileBusy(key) { 34 + t.Error("profile was not busy during callback") 35 + } 36 + }) 37 + if !ran { 38 + t.Fatal("schedule callback did not run") 39 + } 40 + if !doneAfterRelease { 41 + t.Fatal("completion callback ran before profile release") 42 + } 43 + } 44 + 45 + func TestRunScheduleCallbackGuards(t *testing.T) { 46 + t.Run("missing profile", func(t *testing.T) { 47 + setupScheduleCallbackTest(t) 48 + ran := false 49 + runScheduleCallback("missing", nil, func(Config, int, Profile) { ran = true }) 50 + if ran { 51 + t.Fatal("callback ran for missing profile") 52 + } 53 + }) 54 + 55 + t.Run("busy", func(t *testing.T) { 56 + key := setupScheduleCallbackTest(t) 57 + if !acquireProfile(key) { 58 + t.Fatal("could not mark profile busy") 59 + } 60 + defer releaseProfile(key) 61 + ran := false 62 + runScheduleCallback(key, nil, func(Config, int, Profile) { ran = true }) 63 + if ran { 64 + t.Fatal("callback ran for busy profile") 65 + } 66 + if !isProfileBusy(key) { 67 + t.Fatal("callback released another operation's profile ownership") 68 + } 69 + }) 70 + } 71 + 72 + func TestBuildDaemonScheduleValidation(t *testing.T) { 73 + tests := []struct { 74 + name string 75 + config string 76 + wantErr bool 77 + }{ 78 + { 79 + name: "missing environment", 80 + config: "[[profiles]]\nname = 'scheduled'\nenv_file = '/missing/profile.env'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", 81 + }, 82 + { 83 + name: "unsupported backend", 84 + config: "[[profiles]]\nname = 'scheduled'\nbackend = 'borg'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", 85 + wantErr: true, 86 + }, 87 + { 88 + name: "missing backup paths", 89 + config: "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\ncron = '0 * * * *'\n", 90 + wantErr: true, 91 + }, 92 + } 93 + 94 + for _, tt := range tests { 95 + t.Run(tt.name, func(t *testing.T) { 96 + writeScheduleConfig(t, tt.config) 97 + sched, err := buildDaemonSchedule() 98 + if (err != nil) != tt.wantErr { 99 + t.Fatalf("buildDaemonSchedule() error = %v, wantErr %v", err, tt.wantErr) 100 + } 101 + if !tt.wantErr && len(sched.Entries()) != 1 { 102 + t.Fatalf("schedule entries = %d, want 1", len(sched.Entries())) 103 + } 104 + }) 105 + } 106 + } 107 + 108 + func TestRunScheduleCallbackPanicCleanup(t *testing.T) { 109 + key := setupScheduleCallbackTest(t) 110 + done := false 111 + runScheduleCallback(key, func() { done = true }, func(Config, int, Profile) { 112 + panic("boom") 113 + }) 114 + if isProfileBusy(key) { 115 + t.Fatal("profile remained busy after panic") 116 + } 117 + if !done { 118 + t.Fatal("completion callback did not run after panic") 119 + } 120 + }
+73 -2
cmd/restray/state.go
··· 4 4 "os/exec" 5 5 "sync" 6 6 "time" 7 + 8 + "github.com/robfig/cron/v3" 7 9 ) 8 10 9 11 type stateKey string 12 + 13 + type profileState struct { 14 + errMsg string 15 + repoErr string 16 + scheduleErr string 17 + needsInit bool 18 + entryID cron.EntryID 19 + } 10 20 11 21 type appState struct { 12 22 mu sync.Mutex ··· 17 27 notifications string 18 28 failStatus map[stateKey]string 19 29 lastBackup map[stateKey]time.Time 30 + activeProfile stateKey 31 + profileStates map[stateKey]profileState 20 32 } 21 33 22 34 var state = appState{ ··· 25 37 mountStopping: make(map[stateKey]bool), 26 38 failStatus: make(map[stateKey]string), 27 39 lastBackup: make(map[stateKey]time.Time), 40 + profileStates: make(map[stateKey]profileState), 41 + } 42 + 43 + func resetProfileState(keys []stateKey) stateKey { 44 + state.mu.Lock() 45 + defer state.mu.Unlock() 46 + 47 + profiles := make(map[stateKey]profileState, len(keys)) 48 + for _, key := range keys { 49 + profiles[key] = state.profileStates[key] 50 + } 51 + state.profileStates = profiles 52 + if _, ok := profiles[state.activeProfile]; !ok { 53 + state.activeProfile = "" 54 + if len(keys) > 0 { 55 + state.activeProfile = keys[0] 56 + } 57 + } 58 + return state.activeProfile 59 + } 60 + 61 + func activeProfileKey() stateKey { 62 + state.mu.Lock() 63 + defer state.mu.Unlock() 64 + return state.activeProfile 65 + } 66 + 67 + func setActiveProfileKey(key stateKey) { 68 + state.mu.Lock() 69 + if _, ok := state.profileStates[key]; ok { 70 + state.activeProfile = key 71 + } 72 + state.mu.Unlock() 73 + } 74 + 75 + func getProfileState(key stateKey) (profileState, bool) { 76 + state.mu.Lock() 77 + defer state.mu.Unlock() 78 + ps, ok := state.profileStates[key] 79 + return ps, ok 80 + } 81 + 82 + func setProfileProbeState(key stateKey, probe profileState) { 83 + state.mu.Lock() 84 + if current, ok := state.profileStates[key]; ok { 85 + probe.entryID = current.entryID 86 + state.profileStates[key] = probe 87 + } 88 + state.mu.Unlock() 89 + } 90 + 91 + func setProfileCronEntry(key stateKey, entryID cron.EntryID) { 92 + state.mu.Lock() 93 + if current, ok := state.profileStates[key]; ok { 94 + current.entryID = entryID 95 + state.profileStates[key] = current 96 + } 97 + state.mu.Unlock() 28 98 } 29 99 30 100 func setLastBackup(key stateKey, t time.Time) { ··· 94 164 95 165 func cancelProfile(key stateKey) { 96 166 state.mu.Lock() 97 - defer state.mu.Unlock() 98 - if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { 167 + cmd := state.busyProfiles[key] 168 + state.mu.Unlock() 169 + if cmd != nil && cmd.Process != nil { 99 170 interruptProcess(cmd.Process) 100 171 } 101 172 }
+149
cmd/restray/state_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "os/exec" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + const busyTransitionGuard stateKey = "busy-transition-guard" 11 + 12 + func resetProfileStateForTest(t *testing.T) { 13 + t.Helper() 14 + state.mu.Lock() 15 + oldActive := state.activeProfile 16 + oldProfiles := state.profileStates 17 + state.activeProfile = "" 18 + state.profileStates = make(map[stateKey]profileState) 19 + state.mu.Unlock() 20 + t.Cleanup(func() { 21 + state.mu.Lock() 22 + state.activeProfile = oldActive 23 + state.profileStates = oldProfiles 24 + state.mu.Unlock() 25 + }) 26 + } 27 + 28 + func TestProfileStatePartialUpdates(t *testing.T) { 29 + resetProfileStateForTest(t) 30 + key := stateKey("alpha") 31 + resetProfileState([]stateKey{key}) 32 + setProfileCronEntry(key, 42) 33 + want := profileState{errMsg: "config", repoErr: "repository", scheduleErr: "schedule", needsInit: true, entryID: 42} 34 + setProfileProbeState(key, want) 35 + 36 + got, ok := getProfileState(key) 37 + if !ok { 38 + t.Fatal("profile state missing") 39 + } 40 + if got != want { 41 + t.Fatalf("probe update = %+v, want %+v", got, want) 42 + } 43 + 44 + setProfileCronEntry(key, 73) 45 + want.entryID = 73 46 + got, _ = getProfileState(key) 47 + if got != want { 48 + t.Fatalf("cron update = %+v, want %+v", got, want) 49 + } 50 + } 51 + 52 + func TestProfileStateSurvivesReorder(t *testing.T) { 53 + resetProfileStateForTest(t) 54 + alpha := stateKey("alpha") 55 + beta := stateKey("beta") 56 + resetProfileState([]stateKey{alpha, beta}) 57 + setProfileProbeState(alpha, profileState{errMsg: "alpha error"}) 58 + setProfileProbeState(beta, profileState{repoErr: "beta error", needsInit: true}) 59 + setProfileCronEntry(alpha, 11) 60 + setProfileCronEntry(beta, 22) 61 + setActiveProfileKey(beta) 62 + 63 + if active := resetProfileState([]stateKey{beta, alpha}); active != beta { 64 + t.Fatalf("active profile after reorder = %q, want %q", active, beta) 65 + } 66 + alphaState, _ := getProfileState(alpha) 67 + betaState, _ := getProfileState(beta) 68 + if alphaState.errMsg != "alpha error" || alphaState.entryID != 11 { 69 + t.Fatalf("alpha state moved during reorder: %+v", alphaState) 70 + } 71 + if betaState.repoErr != "beta error" || !betaState.needsInit || betaState.entryID != 22 { 72 + t.Fatalf("beta state moved during reorder: %+v", betaState) 73 + } 74 + } 75 + 76 + func TestActiveProfileFallsBackAfterRemoval(t *testing.T) { 77 + resetProfileStateForTest(t) 78 + alpha := stateKey("alpha") 79 + beta := stateKey("beta") 80 + resetProfileState([]stateKey{alpha, beta}) 81 + setActiveProfileKey(beta) 82 + 83 + if active := resetProfileState([]stateKey{alpha}); active != alpha { 84 + t.Fatalf("active profile after removal = %q, want %q", active, alpha) 85 + } 86 + if _, ok := getProfileState(beta); ok { 87 + t.Fatal("removed profile state was retained") 88 + } 89 + if active := resetProfileState(nil); active != "" { 90 + t.Fatalf("active profile with no profiles = %q, want empty", active) 91 + } 92 + } 93 + 94 + func resetBusyState(t *testing.T) { 95 + t.Helper() 96 + 97 + state.mu.Lock() 98 + oldBusyProfiles := state.busyProfiles 99 + oldOnBusyChanged := state.onBusyChanged 100 + state.busyProfiles = map[stateKey]*exec.Cmd{busyTransitionGuard: nil} 101 + state.onBusyChanged = nil 102 + state.mu.Unlock() 103 + 104 + t.Cleanup(func() { 105 + state.mu.Lock() 106 + state.busyProfiles = oldBusyProfiles 107 + state.onBusyChanged = oldOnBusyChanged 108 + state.mu.Unlock() 109 + }) 110 + } 111 + 112 + func TestCancelProfileInterruptsCommand(t *testing.T) { 113 + if os.Getenv("RESTRAY_STATE_TEST_HELPER") == "1" { 114 + time.Sleep(30 * time.Second) 115 + return 116 + } 117 + 118 + resetBusyState(t) 119 + key := stateKey("cancel") 120 + cmd := exec.Command(os.Args[0], "-test.run=^TestCancelProfileInterruptsCommand$") 121 + cmd.Env = append(os.Environ(), "RESTRAY_STATE_TEST_HELPER=1") 122 + if err := cmd.Start(); err != nil { 123 + t.Fatalf("start helper process: %v", err) 124 + } 125 + wait := make(chan error, 1) 126 + go func() { wait <- cmd.Wait() }() 127 + waited := false 128 + t.Cleanup(func() { 129 + if !waited { 130 + _ = cmd.Process.Kill() 131 + <-wait 132 + } 133 + }) 134 + 135 + setProfileBusyCmd(key, cmd) 136 + cancelProfile(key) 137 + 138 + select { 139 + case err := <-wait: 140 + waited = true 141 + if err == nil { 142 + t.Fatal("helper process exited successfully; want interruption") 143 + } 144 + case <-time.After(5 * time.Second): 145 + t.Fatal("busy command did not stop after cancellation") 146 + } 147 + 148 + releaseProfile(key) 149 + }
+734 -739
cmd/restray/tray.go
··· 9 9 "time" 10 10 11 11 "fyne.io/systray" 12 - "github.com/distatus/battery" 13 12 "github.com/dustin/go-humanize" 14 - hcron "github.com/lnquy/cron" 15 13 "github.com/robfig/cron/v3" 16 14 ) 17 15 ··· 20 18 maxMenuWidth = 64 21 19 ) 22 20 23 - var cronDescriptor, _ = hcron.NewDescriptor() 24 - 25 - func describeCron(expr string) string { 26 - desc, err := cronDescriptor.ToDescription(expr, hcron.Locale_en) 27 - if err != nil { 28 - return expr 29 - } 30 - desc = strings.TrimSpace(desc) 31 - if len(desc) > 0 { 32 - desc = strings.ToLower(desc[:1]) + desc[1:] 33 - } 34 - return desc 35 - } 36 - 37 21 type prefixedMenuItem struct { 38 22 *systray.MenuItem 39 23 prefix string ··· 47 31 p.MenuItem.SetTitle(title) 48 32 } 49 33 50 - type profileState struct { 51 - errMsg string 52 - repoErr string 53 - needsInit bool 54 - } 55 - 56 - var ( 57 - profileMu sync.Mutex 58 - activeProfile int 59 - profileStates []profileState 60 - profileEntryIDs map[int][]cron.EntryID 61 - ) 62 - 63 - func getActiveProfile() int { 64 - profileMu.Lock() 65 - defer profileMu.Unlock() 66 - return activeProfile 67 - } 68 - 69 34 func setProfileFailed(key stateKey, status string) { 70 35 state.mu.Lock() 71 36 if status == "" { ··· 84 49 85 50 func anyError() bool { 86 51 state.mu.Lock() 87 - hasFail := len(state.failStatus) > 0 88 - state.mu.Unlock() 89 - if hasFail { 52 + defer state.mu.Unlock() 53 + if len(state.failStatus) > 0 { 90 54 return true 91 55 } 92 - profileMu.Lock() 93 - defer profileMu.Unlock() 94 - for _, p := range profileStates { 95 - if p.errMsg != "" || p.repoErr != "" { 56 + for _, ps := range state.profileStates { 57 + if ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" { 96 58 return true 97 59 } 98 60 } ··· 107 69 } 108 70 109 71 func unreachableRecovered(cfg Config) bool { 110 - profileMu.Lock() 111 72 var unreachable []Profile 112 - for i, p := range profileStates { 113 - if p.errMsg == "" && p.repoErr != "" && i < len(cfg.Profiles) { 114 - unreachable = append(unreachable, cfg.Profiles[i]) 73 + for _, prof := range cfg.Profiles { 74 + if ps, ok := getProfileState(prof.profileKey()); ok && ps.errMsg == "" && ps.repoErr != "" { 75 + unreachable = append(unreachable, prof) 115 76 } 116 77 } 117 - profileMu.Unlock() 118 - 119 78 for _, prof := range unreachable { 120 79 if repoStatus(prof).errMsg == "" { 121 80 return true ··· 187 146 }() 188 147 } 189 148 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 149 func startAppUpdateChecker(item *systray.MenuItem) { 202 150 go func() { 203 151 check := func() { ··· 220 168 }() 221 169 } 222 170 223 - func onReady() { 224 - firstLaunch := !fileExists(configPath()) 171 + type trayController struct { 172 + mGlobalStatus prefixedMenuItem 173 + mStatusItems [maxProfiles]*systray.MenuItem 174 + mProfile *systray.MenuItem 175 + profileItems [maxProfiles]*systray.MenuItem 176 + mDownload *systray.MenuItem 177 + mUpdate *systray.MenuItem 178 + mSchedule *systray.MenuItem 179 + mCancel *systray.MenuItem 180 + mInit *systray.MenuItem 181 + mRepo *systray.MenuItem 182 + mBackup *systray.MenuItem 183 + mPrune *systray.MenuItem 184 + mCheck *systray.MenuItem 185 + mUnlock *systray.MenuItem 186 + mMount *systray.MenuItem 187 + mConsole *systray.MenuItem 188 + mPreHook *systray.MenuItem 189 + mPostHook *systray.MenuItem 190 + 191 + mFDA *systray.MenuItem 192 + mWebEditor *systray.MenuItem 193 + mSettings *systray.MenuItem 194 + mEnv *systray.MenuItem 195 + mFixPerms *systray.MenuItem 196 + mLog *systray.MenuItem 197 + mFolder *systray.MenuItem 198 + mAbout *systray.MenuItem 199 + mQuit *systray.MenuItem 200 + 201 + applyMu sync.Mutex 202 + pendingFullApply bool 203 + schedulerMu sync.RWMutex 204 + scheduler *cron.Cron 205 + } 206 + 207 + func newTrayController() *trayController { 208 + c := &trayController{} 225 209 applyIconMode(loadConfig().GUI.Icon) 226 210 setIconAnimated("idle") 227 211 systray.SetTooltip("Restray") 228 212 229 - mGlobalStatus := prefixedMenuItem{systray.AddMenuItem("", ""), ""} 230 - mGlobalStatus.Disable() 231 - mGlobalStatus.Hide() 232 - var mStatusItems [maxProfiles]*systray.MenuItem 233 - for i := range mStatusItems { 234 - mStatusItems[i] = systray.AddMenuItem("", "") 235 - mStatusItems[i].Disable() 236 - mStatusItems[i].Hide() 213 + c.mGlobalStatus = prefixedMenuItem{systray.AddMenuItem("", ""), ""} 214 + c.mGlobalStatus.Disable() 215 + c.mGlobalStatus.Hide() 216 + for i := range c.mStatusItems { 217 + c.mStatusItems[i] = systray.AddMenuItem("", "") 218 + c.mStatusItems[i].Disable() 219 + c.mStatusItems[i].Hide() 237 220 } 238 221 239 222 systray.AddSeparator() 240 - mProfile := systray.AddMenuItem("Profile", "") 241 - mProfile.Disable() 242 - var profileItems [maxProfiles]*systray.MenuItem 243 - for i := range profileItems { 244 - profileItems[i] = mProfile.AddSubMenuItem("", "") 245 - profileItems[i].Hide() 223 + c.mProfile = systray.AddMenuItem("Profile", "") 224 + c.mProfile.Disable() 225 + for i := range c.profileItems { 226 + c.profileItems[i] = c.mProfile.AddSubMenuItem("", "") 227 + c.profileItems[i].Hide() 228 + } 229 + 230 + c.mDownload = systray.AddMenuItem("Install Backend", "Install selected backup backend") 231 + c.mDownload.Hide() 232 + c.mUpdate = systray.AddMenuItem("", "Update backend binary") 233 + c.mUpdate.Hide() 234 + c.mSchedule = systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") 235 + c.mSchedule.Hide() 236 + c.mCancel = systray.AddMenuItem("Cancel Operation", "Cancel running operation") 237 + c.mCancel.Hide() 238 + c.mInit = systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") 239 + c.mInit.Hide() 240 + c.mRepo = systray.AddMenuItem("Operations", "Repository operations") 241 + c.mRepo.Disable() 242 + c.mBackup = c.mRepo.AddSubMenuItem("Backup", "Run a backup") 243 + c.mBackup.Disable() 244 + c.mPrune = c.mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") 245 + c.mPrune.Disable() 246 + c.mCheck = c.mRepo.AddSubMenuItem("Check", "Verify repository integrity") 247 + c.mCheck.Disable() 248 + c.mUnlock = c.mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") 249 + c.mUnlock.Disable() 250 + c.mMount = c.mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") 251 + c.mConsole = c.mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") 252 + c.mPreHook = c.mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") 253 + c.mPreHook.Disable() 254 + c.mPostHook = c.mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") 255 + c.mPostHook.Disable() 256 + systray.AddSeparator() 257 + configure := systray.AddMenuItem("Configure", "Restray settings") 258 + c.mFDA = configure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") 259 + c.mFDA.Hide() 260 + c.mWebEditor = configure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") 261 + c.mSettings = configure.AddSubMenuItem("Edit Config File", "Open config file in editor") 262 + c.mEnv = configure.AddSubMenuItem("Edit Env File", "Open env file in editor") 263 + c.mFixPerms = configure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") 264 + c.mFixPerms.Hide() 265 + c.mLog = configure.AddSubMenuItem("View Log", "Open log file in editor") 266 + c.mFolder = configure.AddSubMenuItem("Open Folder", "Open folder in file manager") 267 + c.mAbout = configure.AddSubMenuItem("Restray v"+version, "Open repository in browser") 268 + systray.AddSeparator() 269 + c.mQuit = systray.AddMenuItem("Quit", "Quit Restray") 270 + return c 271 + } 272 + 273 + func onReady() { 274 + newTrayController().start(!fileExists(configPath())) 275 + } 276 + 277 + func (c *trayController) start(firstLaunch bool) { 278 + state.mu.Lock() 279 + state.onBusyChanged = c.onBusyChanged 280 + state.mu.Unlock() 281 + go c.applyConfig() 282 + if firstLaunch && needsFullDiskAccess() { 283 + go promptFullDiskAccess() 284 + } 285 + watchConfig(c.applyConfig) 286 + 287 + startTrayBackendMonitor(c.selectedProfile, c.applyConfig, c.mUpdate, c.mGlobalStatus) 288 + startTrayStatusMonitor(c.applyConfig, c.updateAllStatusItems, c.applyProfileUI) 289 + go c.handleClicks() 290 + 291 + if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { 292 + startAppUpdateChecker(c.mAbout) 246 293 } 294 + c.startProfileSelectionHandlers() 295 + } 247 296 248 - setProfileTitle := func(cfg Config, idx int) { 249 - if idx < len(cfg.Profiles) { 250 - mProfile.SetTitle(cfg.Profiles[idx].displayName()) 251 - } 252 - if len(cfg.Profiles) > 1 { 253 - mProfile.Enable() 254 - } else { 255 - mProfile.Disable() 256 - } 297 + func (c *trayController) onBusyChanged(busy bool) { 298 + if busy { 299 + c.mCancel.Show() 300 + c.mSchedule.Hide() 301 + c.mInit.Hide() 302 + c.mBackup.Disable() 303 + c.mPrune.Disable() 304 + c.mCheck.Disable() 305 + c.mUnlock.Disable() 306 + c.mPreHook.Disable() 307 + c.mPostHook.Disable() 257 308 } 309 + } 258 310 259 - markActiveProfile := func(active int, count int) { 260 - for i := 0; i < count && i < maxProfiles; i++ { 261 - if i == active { 262 - profileItems[i].Check() 263 - profileItems[i].Disable() 264 - } else { 265 - profileItems[i].Uncheck() 266 - profileItems[i].Enable() 267 - } 268 - } 311 + func (c *trayController) stopScheduler() { 312 + c.schedulerMu.RLock() 313 + defer c.schedulerMu.RUnlock() 314 + if c.scheduler != nil { 315 + c.scheduler.Stop() 269 316 } 317 + } 270 318 271 - mDownload := systray.AddMenuItem("Install Backend", "Install selected backup backend") 272 - mDownload.Hide() 273 - mUpdate := systray.AddMenuItem("", "Update backend binary") 274 - mUpdate.Hide() 275 - mSchedule := systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") 276 - mSchedule.Hide() 277 - mCancel := systray.AddMenuItem("Cancel Operation", "Cancel running operation") 278 - mCancel.Hide() 279 - mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") 280 - mInit.Hide() 281 - mRepo := systray.AddMenuItem("Operations", "Repository operations") 282 - mRepo.Disable() 283 - mBackup := mRepo.AddSubMenuItem("Backup", "Run a backup") 284 - mBackup.Disable() 285 - mPrune := mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") 286 - mPrune.Disable() 287 - mCheck := mRepo.AddSubMenuItem("Check", "Verify repository integrity") 288 - mCheck.Disable() 289 - mUnlock := mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") 290 - mUnlock.Disable() 291 - mMount := mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") 292 - mConsole := mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") 293 - mPreHook := mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") 294 - mPreHook.Disable() 295 - mPostHook := mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") 296 - mPostHook.Disable() 297 - systray.AddSeparator() 298 - mConfigure := systray.AddMenuItem("Configure", "Restray settings") 299 - mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") 300 - mFDA.Hide() 301 - mWebEditor := mConfigure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") 302 - mSettings := mConfigure.AddSubMenuItem("Edit Config File", "Open config file in editor") 303 - mEnv := mConfigure.AddSubMenuItem("Edit Env File", "Open env file in editor") 304 - mFixPerms := mConfigure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") 305 - mFixPerms.Hide() 306 - mLog := mConfigure.AddSubMenuItem("View Log", "Open log file in editor") 307 - mFolder := mConfigure.AddSubMenuItem("Open Folder", "Open folder in file manager") 308 - mAbout := mConfigure.AddSubMenuItem("Restray v"+version, "Open repository in browser") 309 - systray.AddSeparator() 310 - mQuit := systray.AddMenuItem("Quit", "Quit Restray") 319 + func (c *trayController) formatNext(entryID cron.EntryID) string { 320 + c.schedulerMu.RLock() 321 + defer c.schedulerMu.RUnlock() 322 + if c.scheduler == nil || entryID == 0 { 323 + return "" 324 + } 325 + entry := c.scheduler.Entry(entryID) 326 + if entry.ID == 0 { 327 + return "" 328 + } 329 + return humanize.Time(entry.Schedule.Next(time.Now())) 330 + } 311 331 312 - sched := cron.New() 313 - var applyConfig func() 314 - var applyMu sync.Mutex 315 - pendingFullApply := false 332 + func profileKeys(cfg Config) []stateKey { 333 + keys := make([]stateKey, 0, len(cfg.Profiles)) 334 + for _, prof := range cfg.Profiles { 335 + keys = append(keys, prof.profileKey()) 336 + } 337 + return keys 338 + } 316 339 317 - state.onBusyChanged = func(busy bool) { 318 - if busy { 319 - mCancel.Show() 320 - mSchedule.Hide() 321 - mInit.Hide() 322 - mBackup.Disable() 323 - mPrune.Disable() 324 - mCheck.Disable() 325 - mUnlock.Disable() 326 - mPreHook.Disable() 327 - mPostHook.Disable() 328 - } 340 + func (c *trayController) activeProfile(cfg Config) (int, Profile, bool) { 341 + if idx, prof, err := resolveProfileIndex(cfg, activeProfileKey()); err == nil { 342 + return idx, prof, true 343 + } 344 + if len(cfg.Profiles) == 0 || cfg.loadErr != nil { 345 + return 0, Profile{}, false 329 346 } 347 + return 0, cfg.Profiles[0], true 348 + } 330 349 331 - createOrEdit := func(m *systray.MenuItem, label, path string) { 332 - if _, err := os.Stat(path); err != nil { 333 - m.SetTitle("Create " + label) 350 + func (c *trayController) selectedProfile() Profile { 351 + _, prof, ok := c.activeProfile(loadConfig()) 352 + if ok { 353 + return prof 354 + } 355 + return Profile{} 356 + } 357 + 358 + func (c *trayController) setProfileTitle(cfg Config, idx int) { 359 + if idx < len(cfg.Profiles) { 360 + c.mProfile.SetTitle(cfg.Profiles[idx].displayName()) 361 + } 362 + if len(cfg.Profiles) > 1 { 363 + c.mProfile.Enable() 364 + } else { 365 + c.mProfile.Disable() 366 + } 367 + } 368 + 369 + func (c *trayController) markActiveProfile(active, count int) { 370 + for i := 0; i < count && i < maxProfiles; i++ { 371 + if i == active { 372 + c.profileItems[i].Check() 373 + c.profileItems[i].Disable() 334 374 } else { 335 - m.SetTitle("Edit " + label) 375 + c.profileItems[i].Uncheck() 376 + c.profileItems[i].Enable() 336 377 } 337 - m.Enable() 338 378 } 379 + } 339 380 340 - hideActions := func() { 341 - mSchedule.Hide() 342 - mInit.Hide() 343 - mDownload.Hide() 344 - mUpdate.Hide() 345 - mRepo.Disable() 381 + func createOrEdit(m *systray.MenuItem, label, path string) { 382 + if _, err := os.Stat(path); err != nil { 383 + m.SetTitle("Create " + label) 384 + } else { 385 + m.SetTitle("Edit " + label) 346 386 } 387 + m.Enable() 388 + } 347 389 348 - selectedProfile := func() Profile { 349 - cfg := loadConfig() 350 - idx := getActiveProfile() 351 - if idx < len(cfg.Profiles) { 352 - return cfg.Profiles[idx] 353 - } 354 - if len(cfg.Profiles) > 0 { 355 - return cfg.Profiles[0] 356 - } 357 - return Profile{} 358 - } 390 + func (c *trayController) hideActions() { 391 + c.mSchedule.Hide() 392 + c.mInit.Hide() 393 + c.mDownload.Hide() 394 + c.mUpdate.Hide() 395 + c.mRepo.Disable() 396 + } 359 397 360 - doDownload := func() { 361 - setIconAnimated("download") 362 - if err := downloadBackend(selectedProfile(), mGlobalStatus); err != nil { 363 - setIconAnimated("fail") 364 - mGlobalStatus.SetTitle("Download failed: " + err.Error()) 365 - mDownload.Show() 366 - return 367 - } 368 - applyConfig() 398 + func (c *trayController) doDownload() { 399 + setIconAnimated("download") 400 + if err := downloadBackend(c.selectedProfile(), c.mGlobalStatus); err != nil { 401 + setIconAnimated("fail") 402 + c.mGlobalStatus.SetTitle("Download failed: " + err.Error()) 403 + c.mDownload.Show() 404 + return 369 405 } 406 + c.applyConfig() 407 + } 370 408 371 - statusItem := func(idx int) prefixedMenuItem { 372 - cfg := loadConfig() 373 - prefix := "" 374 - if idx < len(cfg.Profiles) { 375 - prefix = profilePrefix(cfg, cfg.Profiles[idx]) 376 - } 377 - return prefixedMenuItem{mStatusItems[idx], prefix} 409 + func (c *trayController) profileStatusText(cfg Config, idx int) string { 410 + if idx < 0 || idx >= len(cfg.Profiles) { 411 + return "" 412 + } 413 + prof := cfg.Profiles[idx] 414 + ps, ok := getProfileState(prof.profileKey()) 415 + if !ok { 416 + return "" 378 417 } 418 + prefix := profilePrefix(cfg, prof) 379 419 380 - profileStatusText := func(cfg Config, i int) string { 381 - profileMu.Lock() 382 - if i >= len(profileStates) { 383 - profileMu.Unlock() 384 - return "" 420 + switch { 421 + case ps.errMsg != "": 422 + return prefix + ps.errMsg 423 + case ps.scheduleErr != "": 424 + return prefix + ps.scheduleErr 425 + case ps.repoErr != "": 426 + return prefix + ps.repoErr 427 + default: 428 + paused := schedulePausedForBattery(prof) 429 + if prof.Schedule.Cron == "" { 430 + if cfg.GUI.ScheduleDisplay == "last" { 431 + return prefix + "Unscheduled, last " + formatLastBackup(getLastBackup(prof.profileKey())) 432 + } 433 + return prefix + "Unscheduled" 385 434 } 386 - ps := profileStates[i] 387 - eids := profileEntryIDs[i] 388 - profileMu.Unlock() 389 - prof := cfg.Profiles[i] 390 - prefix := profilePrefix(cfg, prof) 391 - 392 - switch { 393 - case ps.errMsg != "": 394 - return prefix + ps.errMsg 395 - case ps.repoErr != "": 396 - return prefix + ps.repoErr 397 - default: 398 - paused := !prof.Schedule.OnBattery && onBatteryPower() 399 - if prof.Schedule.Cron == "" { 400 - if cfg.GUI.ScheduleDisplay == "last" { 401 - last := formatLastBackup(getLastBackup(prof.profileKey())) 402 - return prefix + "Unscheduled, last " + last 403 - } 404 - return prefix + "Unscheduled" 435 + if paused { 436 + return prefix + "Paused (battery)" 437 + } 438 + next := c.formatNext(ps.entryID) 439 + switch cfg.GUI.ScheduleDisplay { 440 + case "none", "hidden": 441 + if next != "" { 442 + return prefix + next 405 443 } 406 - if paused { 407 - return prefix + "Paused (battery)" 444 + return prefix + "Scheduled" 445 + case "cron": 446 + if next != "" { 447 + return prefix + next + " - " + prof.Schedule.Cron 408 448 } 409 - next := formatNextFor(sched, eids) 410 - display := cfg.GUI.ScheduleDisplay 411 - switch display { 412 - case "none", "hidden": 413 - if next != "" { 414 - return prefix + next 415 - } 416 - return prefix + "Scheduled" 417 - case "cron": 418 - if next != "" { 419 - return prefix + next + " - " + prof.Schedule.Cron 420 - } 421 - return prefix + prof.Schedule.Cron 422 - case "last": 423 - last := formatLastBackup(getLastBackup(prof.profileKey())) 424 - if next != "" { 425 - return prefix + next + ", last " + last 426 - } 427 - return prefix + "last " + last 428 - default: 429 - schedule := describeCron(prof.Schedule.Cron) 430 - if next != "" { 431 - return prefix + next + " - " + schedule 432 - } 433 - return prefix + schedule 449 + return prefix + prof.Schedule.Cron 450 + case "last": 451 + last := formatLastBackup(getLastBackup(prof.profileKey())) 452 + if next != "" { 453 + return prefix + next + ", last " + last 454 + } 455 + return prefix + "last " + last 456 + default: 457 + schedule := describeCron(prof.Schedule.Cron) 458 + if next != "" { 459 + return prefix + next + " - " + schedule 434 460 } 461 + return prefix + schedule 435 462 } 436 463 } 464 + } 437 465 438 - updateAllStatusItems := func(cfg Config) { 439 - mGlobalStatus.Hide() 440 - for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { 441 - if !isProfileBusy(cfg.Profiles[i].profileKey()) { 442 - if failMsg := getProfileFailStatus(cfg.Profiles[i].profileKey()); failMsg != "" { 443 - statusItem(i).SetTitle(failMsg) 444 - } else { 445 - mStatusItems[i].SetTitle(profileStatusText(cfg, i)) 446 - } 466 + func (c *trayController) updateAllStatusItems(cfg Config) { 467 + c.mGlobalStatus.Hide() 468 + for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { 469 + prof := cfg.Profiles[i] 470 + if !isProfileBusy(prof.profileKey()) { 471 + if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" { 472 + prefixedMenuItem{c.mStatusItems[i], profilePrefix(cfg, prof)}.SetTitle(failMsg) 473 + } else { 474 + c.mStatusItems[i].SetTitle(c.profileStatusText(cfg, i)) 447 475 } 448 - mStatusItems[i].Show() 449 476 } 450 - for i := len(cfg.Profiles); i < maxProfiles; i++ { 451 - mStatusItems[i].Hide() 452 - } 477 + c.mStatusItems[i].Show() 453 478 } 479 + for i := len(cfg.Profiles); i < maxProfiles; i++ { 480 + c.mStatusItems[i].Hide() 481 + } 482 + } 454 483 455 - applyProfileUI := func(cfg Config) { 456 - profileMu.Lock() 457 - idx := activeProfile 458 - if idx >= len(profileStates) || idx >= len(cfg.Profiles) { 459 - profileMu.Unlock() 460 - return 461 - } 462 - ps := profileStates[idx] 463 - profileMu.Unlock() 464 - prof := cfg.Profiles[idx] 484 + func (c *trayController) applyProfileUI(cfg Config) { 485 + _, prof, ok := c.activeProfile(cfg) 486 + if !ok { 487 + return 488 + } 489 + ps, ok := getProfileState(prof.profileKey()) 490 + if !ok { 491 + return 492 + } 465 493 466 - createOrEdit(mEnv, "Env File", prof.EnvFile) 467 - if envFilesInsecure(cfg.Profiles) { 468 - mFixPerms.Show() 469 - } else { 470 - mFixPerms.Hide() 471 - } 494 + createOrEdit(c.mEnv, "Env File", prof.EnvFile) 495 + if envFilesInsecure(cfg.Profiles) { 496 + c.mFixPerms.Show() 497 + } else { 498 + c.mFixPerms.Hide() 499 + } 472 500 473 - mSchedule.Hide() 474 - mInit.Hide() 475 - mRepo.Disable() 476 - mPreHook.Disable() 477 - mPostHook.Disable() 501 + c.mSchedule.Hide() 502 + c.mInit.Hide() 503 + c.mRepo.Disable() 504 + c.mPreHook.Disable() 505 + c.mPostHook.Disable() 478 506 479 - mMount.Show() 480 - if isProfileMounted(prof.profileKey()) { 481 - mMount.SetTitle("Unmount") 482 - mMount.Enable() 483 - } else if mountSupported(prof) { 484 - mMount.SetTitle("Mount") 485 - mMount.Enable() 507 + c.mMount.Show() 508 + if isProfileMounted(prof.profileKey()) { 509 + c.mMount.SetTitle("Unmount") 510 + c.mMount.Enable() 511 + } else if mountSupported(prof) { 512 + c.mMount.SetTitle("Mount") 513 + c.mMount.Enable() 514 + } else { 515 + c.mMount.SetTitle("Mount") 516 + c.mMount.Disable() 517 + } 518 + 519 + if isProfileBusy(prof.profileKey()) { 520 + c.mCancel.Show() 521 + c.mRepo.Enable() 522 + c.mBackup.Disable() 523 + c.mPrune.Disable() 524 + c.mCheck.Disable() 525 + c.mUnlock.Disable() 526 + return 527 + } 528 + 529 + c.mCancel.Hide() 530 + switch { 531 + case ps.errMsg != "": 532 + case ps.repoErr != "": 533 + if ps.needsInit { 534 + c.mInit.Show() 535 + } 536 + default: 537 + c.mRepo.Enable() 538 + if len(prof.Backup.Paths) > 0 { 539 + c.mBackup.Enable() 486 540 } else { 487 - mMount.SetTitle("Mount") 488 - mMount.Disable() 541 + c.mBackup.Disable() 489 542 } 490 - 491 - if isProfileBusy(prof.profileKey()) { 492 - mCancel.Show() 493 - mRepo.Enable() 494 - mBackup.Disable() 495 - mPrune.Disable() 496 - mCheck.Disable() 497 - mUnlock.Disable() 543 + c.mPrune.Enable() 544 + c.mCheck.Enable() 545 + if prof.backend().supportsUnlock() { 546 + c.mUnlock.Enable() 498 547 } else { 499 - mCancel.Hide() 500 - switch { 501 - case ps.errMsg != "": 502 - case ps.repoErr != "": 503 - if ps.needsInit { 504 - mInit.Show() 505 - } 506 - default: 507 - mRepo.Enable() 508 - mBackup.Enable() 509 - mPrune.Enable() 510 - mCheck.Enable() 511 - if prof.backend().supportsUnlock() { 512 - mUnlock.Enable() 513 - } else { 514 - mUnlock.Disable() 515 - } 516 - mSchedule.Show() 517 - mSchedule.Enable() 518 - if prof.PreHook != "" { 519 - mPreHook.Enable() 520 - } 521 - if prof.PostHook != "" { 522 - mPostHook.Enable() 523 - } 524 - } 548 + c.mUnlock.Disable() 549 + } 550 + c.mSchedule.Show() 551 + if prof.scheduleDefinitionError() == "" { 552 + c.mSchedule.Enable() 553 + } else { 554 + c.mSchedule.Disable() 555 + } 556 + if prof.PreHook != "" { 557 + c.mPreHook.Enable() 558 + } 559 + if prof.PostHook != "" { 560 + c.mPostHook.Enable() 525 561 } 526 562 } 563 + } 527 564 528 - probeProfileState := func(cfg Config, i int, prof Profile) profileState { 529 - var ps profileState 530 - if path, _ := findBackend(prof); path == "" { 531 - ps.errMsg = prof.backendDisplayName() + " not found" 532 - return ps 533 - } 534 - ps.errMsg = prof.profileError() 535 - if ps.errMsg == "" { 536 - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() 537 - var rs repoResult 538 - if wantsLast { 539 - var last time.Time 540 - rs, last = repoStatusAndLastBackup(prof) 541 - if !last.IsZero() { 542 - setLastBackup(prof.profileKey(), last) 543 - } 544 - } else { 545 - rs = repoStatus(prof) 565 + func (c *trayController) probeProfileState(cfg Config, prof Profile) profileState { 566 + var ps profileState 567 + if prof.Schedule.Cron != "" { 568 + ps.scheduleErr = prof.scheduleDefinitionError() 569 + if ps.scheduleErr == "" { 570 + if _, err := cron.ParseStandard(prof.Schedule.Cron); err != nil { 571 + ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron 546 572 } 547 - ps.repoErr = rs.errMsg 548 - ps.needsInit = rs.needsInit 549 573 } 574 + } 575 + if path, _ := findBackend(prof); path == "" { 576 + ps.errMsg = prof.backendDisplayName() + " not found" 550 577 return ps 551 578 } 552 - 553 - refreshProfile := func(idx int) { 554 - applyMu.Lock() 555 - cfg := loadConfig() 556 - if idx < 0 || idx >= len(cfg.Profiles) || idx >= len(profileStates) || idx >= maxProfiles { 557 - applyMu.Unlock() 558 - applyConfig() 559 - return 560 - } 561 - defer applyMu.Unlock() 562 - prof := cfg.Profiles[idx] 563 - mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") 564 - ps := probeProfileState(cfg, idx, prof) 565 - profileMu.Lock() 566 - if idx < len(profileStates) { 567 - profileStates[idx] = ps 568 - } 569 - profileMu.Unlock() 570 - if !isProfileBusy(prof.profileKey()) { 571 - if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" { 572 - statusItem(idx).SetTitle(failMsg) 573 - } else { 574 - mStatusItems[idx].SetTitle(profileStatusText(cfg, idx)) 579 + ps.errMsg = prof.repositoryError() 580 + if ps.errMsg == "" { 581 + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() 582 + var rs repoResult 583 + if wantsLast { 584 + var last time.Time 585 + rs, last = repoStatusAndLastBackup(prof) 586 + if !last.IsZero() { 587 + setLastBackup(prof.profileKey(), last) 575 588 } 589 + } else { 590 + rs = repoStatus(prof) 576 591 } 577 - if !isAnyBusy() { 578 - if anyError() { 579 - setIconAnimated("fail") 580 - } else { 581 - setIconAnimated("idle") 582 - } 583 - } 584 - applyProfileUI(cfg) 592 + ps.repoErr = rs.errMsg 593 + ps.needsInit = rs.needsInit 585 594 } 595 + return ps 596 + } 586 597 587 - finishProfile := func(idx int) { 588 - applyMu.Lock() 589 - pending := pendingFullApply 590 - pendingFullApply = false 591 - applyMu.Unlock() 592 - if pending { 593 - applyConfig() 594 - return 595 - } 596 - refreshProfile(idx) 598 + func (c *trayController) refreshProfile(key stateKey) { 599 + c.applyMu.Lock() 600 + cfg := loadConfig() 601 + idx, prof, err := resolveProfileIndex(cfg, key) 602 + if err != nil || idx >= maxProfiles { 603 + c.applyMu.Unlock() 604 + c.applyConfig() 605 + return 597 606 } 607 + defer c.applyMu.Unlock() 598 608 599 - applyConfig = func() { 600 - applyMu.Lock() 601 - defer applyMu.Unlock() 602 - if isAnyBusy() { 603 - pendingFullApply = true 604 - cfg := loadConfig() 605 - if applyIconMode(cfg.GUI.Icon) { 606 - refreshIcon() 607 - } 608 - profileMu.Lock() 609 - if activeProfile >= len(cfg.Profiles) { 610 - activeProfile = 0 611 - } 612 - activeIdx := activeProfile 613 - profileMu.Unlock() 614 - markActiveProfile(activeIdx, len(cfg.Profiles)) 615 - setProfileTitle(cfg, activeIdx) 616 - updateAllStatusItems(cfg) 617 - applyProfileUI(cfg) 618 - return 609 + c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") 610 + setProfileProbeState(key, c.probeProfileState(cfg, prof)) 611 + if !isProfileBusy(key) { 612 + if failMsg := getProfileFailStatus(key); failMsg != "" { 613 + prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}.SetTitle(failMsg) 614 + } else { 615 + c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) 619 616 } 620 - pendingFullApply = false 621 - cfg := loadConfig() 622 - applyIconMode(cfg.GUI.Icon) 623 - if cfg.loadErr != nil { 617 + } 618 + if !isAnyBusy() { 619 + if anyError() { 624 620 setIconAnimated("fail") 625 - mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr)) 626 - mGlobalStatus.Show() 627 - hideActions() 628 - mProfile.Disable() 629 - for i := range mStatusItems { 630 - mStatusItems[i].Hide() 631 - profileItems[i].Hide() 632 - } 633 - return 621 + } else { 622 + setIconAnimated("idle") 634 623 } 635 - state.mu.Lock() 636 - state.notifications = strings.ToLower(cfg.GUI.Notifications) 637 - state.mu.Unlock() 638 - sched.Stop() 639 - sched = cron.New() 624 + } 625 + c.applyProfileUI(cfg) 626 + } 640 627 641 - createOrEdit(mSettings, "Config File", configPath()) 642 - 643 - mDownload.Hide() 644 - mUpdate.Hide() 628 + func (c *trayController) finishProfile(key stateKey) { 629 + c.applyMu.Lock() 630 + pending := c.pendingFullApply 631 + c.pendingFullApply = false 632 + c.applyMu.Unlock() 633 + if pending { 634 + c.applyConfig() 635 + return 636 + } 637 + c.refreshProfile(key) 638 + } 645 639 646 - prof := selectedProfile() 647 - backendPath, managed := findBackend(prof) 648 - backendName := prof.backendDisplayName() 640 + func (c *trayController) applyConfig() { 641 + c.applyMu.Lock() 642 + defer c.applyMu.Unlock() 649 643 650 - if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { 651 - mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") 652 - mGlobalStatus.Show() 653 - go doDownload() 654 - return 644 + if isAnyBusy() { 645 + c.pendingFullApply = true 646 + cfg := loadConfig() 647 + if applyIconMode(cfg.GUI.Icon) { 648 + refreshIcon() 655 649 } 656 - if backendPath == "" { 657 - setIconAnimated("fail") 658 - mGlobalStatus.SetTitle(backendName + " not found") 659 - mGlobalStatus.Show() 660 - hideActions() 661 - mDownload.SetTitle("Install " + backendName) 662 - mDownload.Show() 663 - mProfile.Disable() 664 - return 650 + activeKey := resetProfileState(profileKeys(cfg)) 651 + activeIdx, _, err := resolveProfileIndex(cfg, activeKey) 652 + if err != nil { 653 + activeIdx = 0 665 654 } 655 + c.markActiveProfile(activeIdx, len(cfg.Profiles)) 656 + c.setProfileTitle(cfg, activeIdx) 657 + c.updateAllStatusItems(cfg) 658 + c.applyProfileUI(cfg) 659 + return 660 + } 666 661 667 - if needsFullDiskAccess() { 668 - mFDA.Show() 669 - } else { 670 - mFDA.Hide() 662 + c.pendingFullApply = false 663 + cfg := loadConfig() 664 + applyIconMode(cfg.GUI.Icon) 665 + if cfg.loadErr != nil { 666 + setIconAnimated("fail") 667 + c.mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr)) 668 + c.mGlobalStatus.Show() 669 + c.hideActions() 670 + c.mProfile.Disable() 671 + for i := range c.mStatusItems { 672 + c.mStatusItems[i].Hide() 673 + c.profileItems[i].Hide() 671 674 } 675 + return 676 + } 677 + state.mu.Lock() 678 + state.notifications = strings.ToLower(cfg.GUI.Notifications) 679 + state.mu.Unlock() 672 680 673 - profileMu.Lock() 674 - profileStates = make([]profileState, len(cfg.Profiles)) 675 - profileEntryIDs = make(map[int][]cron.EntryID) 676 - if activeProfile >= len(cfg.Profiles) { 677 - activeProfile = 0 678 - } 679 - activeIdx := activeProfile 680 - profileMu.Unlock() 681 + c.schedulerMu.Lock() 682 + oldScheduler := c.scheduler 683 + c.scheduler = nil 684 + c.schedulerMu.Unlock() 685 + if oldScheduler != nil { 686 + oldScheduler.Stop() 687 + } 681 688 682 - markActiveProfile(activeIdx, len(cfg.Profiles)) 683 - setProfileTitle(cfg, activeIdx) 689 + createOrEdit(c.mSettings, "Config File", configPath()) 690 + c.mDownload.Hide() 691 + c.mUpdate.Hide() 684 692 685 - sched.Start() 686 - setIconAnimated("busy") 693 + keys := profileKeys(cfg) 694 + activeKey := resetProfileState(keys) 695 + for _, key := range keys { 696 + setProfileCronEntry(key, 0) 697 + } 698 + activeIdx, prof, err := resolveProfileIndex(cfg, activeKey) 699 + if err != nil { 700 + activeIdx = 0 701 + prof = Profile{} 702 + } 703 + backendPath, managed := findBackend(prof) 704 + backendName := prof.backendDisplayName() 687 705 688 - var wg sync.WaitGroup 689 - for i, prof := range cfg.Profiles { 690 - label := prof.displayName() 691 - if i < maxProfiles { 692 - connecting := "Connecting..." 693 - if len(cfg.Profiles) > 1 { 694 - connecting = label + " - " + connecting 695 - } 696 - mStatusItems[i].SetTitle(connecting) 697 - mStatusItems[i].Show() 698 - profileItems[i].SetTitle(label) 699 - profileItems[i].Show() 700 - } 701 - wg.Add(1) 702 - go func(i int, prof Profile) { 703 - defer wg.Done() 704 - ps := probeProfileState(cfg, i, prof) 706 + if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { 707 + c.mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") 708 + c.mGlobalStatus.Show() 709 + go c.doDownload() 710 + return 711 + } 712 + if backendPath == "" { 713 + setIconAnimated("fail") 714 + c.mGlobalStatus.SetTitle(backendName + " not found") 715 + c.mGlobalStatus.Show() 716 + c.hideActions() 717 + c.mDownload.SetTitle("Install " + backendName) 718 + c.mDownload.Show() 719 + c.mProfile.Disable() 720 + return 721 + } 705 722 706 - if prof.Schedule.Cron != "" && i < maxProfiles { 707 - if errMsg := prof.scheduleConfigError(); errMsg != "" { 708 - log.Printf("[%s] skipping schedule: %s", prof.displayName(), errMsg) 709 - } else { 710 - key := prof.profileKey() 711 - name := prof.displayName() 712 - eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 713 - current := loadConfig() 714 - idx, prof, err := resolveProfileIndex(current, key) 715 - if err != nil { 716 - log.Printf("[%s] skipping scheduled run: %v", name, err) 717 - return 718 - } 719 - if skipForBattery(prof) { 720 - log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) 721 - return 722 - } 723 - go func() { 724 - defer func() { 725 - if r := recover(); r != nil { 726 - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) 727 - } 728 - }() 729 - runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 730 - }() 731 - }) 732 - if err != nil { 733 - log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) 734 - ps.errMsg = "Invalid cron: " + prof.Schedule.Cron 735 - } else { 736 - profileMu.Lock() 737 - profileEntryIDs[i] = append(profileEntryIDs[i], eid) 738 - profileMu.Unlock() 739 - } 740 - } 741 - } 723 + if needsFullDiskAccess() { 724 + c.mFDA.Show() 725 + } else { 726 + c.mFDA.Hide() 727 + } 742 728 743 - profileMu.Lock() 744 - profileStates[i] = ps 745 - profileMu.Unlock() 746 - if i < maxProfiles { 747 - mStatusItems[i].SetTitle(profileStatusText(cfg, i)) 748 - } 749 - if i == activeIdx { 750 - applyProfileUI(cfg) 751 - } 752 - }(i, prof) 753 - } 754 - wg.Wait() 729 + nextScheduler := cron.New() 730 + nextScheduler.Start() 731 + c.schedulerMu.Lock() 732 + c.scheduler = nextScheduler 733 + c.schedulerMu.Unlock() 755 734 756 - for i := len(cfg.Profiles); i < maxProfiles; i++ { 757 - profileItems[i].Hide() 758 - profileItems[i].Uncheck() 759 - } 735 + c.markActiveProfile(activeIdx, len(cfg.Profiles)) 736 + c.setProfileTitle(cfg, activeIdx) 737 + setIconAnimated("busy") 760 738 761 - if anyError() { 762 - setIconAnimated("fail") 763 - } else { 764 - setIconAnimated("idle") 739 + var wg sync.WaitGroup 740 + for i, prof := range cfg.Profiles { 741 + label := prof.displayName() 742 + if i < maxProfiles { 743 + connecting := "Connecting..." 744 + if len(cfg.Profiles) > 1 { 745 + connecting = label + " - " + connecting 746 + } 747 + c.mStatusItems[i].SetTitle(connecting) 748 + c.mStatusItems[i].Show() 749 + c.profileItems[i].SetTitle(label) 750 + c.profileItems[i].Show() 765 751 } 752 + wg.Add(1) 753 + go func() { 754 + defer wg.Done() 755 + c.initializeProfile(cfg, nextScheduler, i, prof, activeKey) 756 + }() 757 + } 758 + wg.Wait() 766 759 767 - updateAllStatusItems(cfg) 768 - applyProfileUI(cfg) 760 + for i := len(cfg.Profiles); i < maxProfiles; i++ { 761 + c.profileItems[i].Hide() 762 + c.profileItems[i].Uncheck() 763 + } 764 + if anyError() { 765 + setIconAnimated("fail") 766 + } else { 767 + setIconAnimated("idle") 768 + } 769 + c.updateAllStatusItems(cfg) 770 + c.applyProfileUI(cfg) 769 771 770 - if managed { 771 - go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), mUpdate, mGlobalStatus, applyConfig) 772 - } 772 + if managed { 773 + go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), c.mUpdate, c.mGlobalStatus, c.applyConfig) 773 774 } 775 + } 774 776 775 - go applyConfig() 776 - if firstLaunch && needsFullDiskAccess() { 777 - go promptFullDiskAccess() 777 + func (c *trayController) initializeProfile(cfg Config, sched *cron.Cron, idx int, prof Profile, activeKey stateKey) { 778 + key := prof.profileKey() 779 + ps := c.probeProfileState(cfg, prof) 780 + if prof.Schedule.Cron != "" && idx < maxProfiles { 781 + if ps.scheduleErr != "" { 782 + log.Printf("[%s] skipping schedule: %s", prof.displayName(), ps.scheduleErr) 783 + } else { 784 + eid, err := sched.AddFunc(prof.Schedule.Cron, func() { 785 + runScheduleCallback(key, func() { c.finishProfile(key) }, func(currentCfg Config, currentIdx int, current Profile) { 786 + if currentIdx >= maxProfiles { 787 + return 788 + } 789 + item := prefixedMenuItem{c.mStatusItems[currentIdx], profilePrefix(currentCfg, current)} 790 + runScheduledAcquired(key, item, current) 791 + }) 792 + }) 793 + if err != nil { 794 + log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) 795 + ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron 796 + } else { 797 + setProfileCronEntry(key, eid) 798 + } 799 + } 778 800 } 779 - watchConfig(applyConfig) 801 + setProfileProbeState(key, ps) 802 + if idx < maxProfiles { 803 + c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) 804 + } 805 + if key == activeKey { 806 + c.applyProfileUI(cfg) 807 + } 808 + } 780 809 781 - startTrayBackendMonitor(selectedProfile, applyConfig, mUpdate, mGlobalStatus) 782 - startTrayStatusMonitor(applyConfig, updateAllStatusItems, applyProfileUI) 810 + func (c *trayController) selectedOperation() (Profile, prefixedMenuItem, bool) { 811 + cfg := loadConfig() 812 + idx, prof, ok := c.activeProfile(cfg) 813 + if !ok || idx >= maxProfiles { 814 + return Profile{}, prefixedMenuItem{}, false 815 + } 816 + return prof, prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}, true 817 + } 783 818 784 - repoOp := func(status string, args ...string) { 785 - idx := getActiveProfile() 786 - prof := selectedProfile() 787 - if idx >= maxProfiles { 819 + func (c *trayController) repoOp(prof Profile, ms prefixedMenuItem, status string, args ...string) { 820 + key := prof.profileKey() 821 + go func() { 822 + if !acquireProfile(key) { 788 823 return 789 824 } 790 - ms := statusItem(idx) 791 - go func() { 792 - if !acquireProfile(prof.profileKey()) { 793 - return 825 + setProfileFailed(key, "") 826 + defer c.finishProfile(key) 827 + defer releaseProfile(key) 828 + ms.SetTitle(status) 829 + if msg, err := runBackend(key, prof, ms, args...); err != nil { 830 + setProfileFailed(key, msg) 831 + notifyError(args[0], msg) 832 + } else { 833 + notifySuccess(args[0]) 834 + } 835 + }() 836 + } 837 + 838 + func (c *trayController) runManualOperation(run func(stateKey, prefixedMenuItem, Profile, func())) { 839 + prof, item, ok := c.selectedOperation() 840 + if !ok { 841 + return 842 + } 843 + key := prof.profileKey() 844 + go run(key, item, prof, func() { c.finishProfile(key) }) 845 + } 846 + 847 + func (c *trayController) handleClicks() { 848 + for { 849 + select { 850 + case <-c.mSchedule.ClickedCh: 851 + c.runManualOperation(runScheduled) 852 + case <-c.mBackup.ClickedCh: 853 + c.runManualOperation(runBackup) 854 + case <-c.mCancel.ClickedCh: 855 + cancelProfile(c.selectedProfile().profileKey()) 856 + case <-c.mPrune.ClickedCh: 857 + if prof, item, ok := c.selectedOperation(); ok && len(prof.Prune.Args) > 0 { 858 + c.repoOp(prof, item, "Pruning repository...", forgetArgs(prof)...) 794 859 } 795 - setProfileFailed(prof.profileKey(), "") 796 - defer finishProfile(idx) 797 - defer releaseProfile(prof.profileKey()) 798 - ms.SetTitle(status) 799 - if msg, err := runBackend(prof.profileKey(), prof, ms, args...); err != nil { 800 - setProfileFailed(prof.profileKey(), msg) 801 - notifyError(args[0], msg) 860 + case <-c.mCheck.ClickedCh: 861 + if prof, item, ok := c.selectedOperation(); ok { 862 + c.repoOp(prof, item, "Checking repository...", checkArgs(prof)...) 863 + } 864 + case <-c.mUnlock.ClickedCh: 865 + if prof, item, ok := c.selectedOperation(); ok && prof.backend().supportsUnlock() { 866 + c.repoOp(prof, item, "Unlocking repository...", "unlock") 867 + } 868 + case <-c.mInit.ClickedCh: 869 + if prof, item, ok := c.selectedOperation(); ok { 870 + c.repoOp(prof, item, "Initializing repository...", "init") 871 + } 872 + case <-c.mMount.ClickedCh: 873 + prof := c.selectedProfile() 874 + key := prof.profileKey() 875 + if isProfileMounted(key) { 876 + stopProfileMount(key) 802 877 } else { 803 - notifySuccess(args[0]) 878 + go startMount(key, prof, c.mMount, func() { c.finishProfile(key) }) 804 879 } 805 - }() 806 - } 807 - 808 - go func() { 809 - for { 810 - select { 811 - case <-mSchedule.ClickedCh: 812 - idx := getActiveProfile() 813 - prof := selectedProfile() 814 - if idx < maxProfiles { 815 - go runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 816 - } 817 - case <-mBackup.ClickedCh: 818 - idx := getActiveProfile() 819 - prof := selectedProfile() 820 - if idx < maxProfiles { 821 - go runBackup(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) 822 - } 823 - case <-mCancel.ClickedCh: 824 - cancelProfile(selectedProfile().profileKey()) 825 - case <-mPrune.ClickedCh: 826 - prof := selectedProfile() 827 - if len(prof.Prune.Args) > 0 { 828 - repoOp("Pruning repository...", forgetArgs(prof)...) 829 - } 830 - case <-mCheck.ClickedCh: 831 - repoOp("Checking repository...", checkArgs(selectedProfile())...) 832 - case <-mUnlock.ClickedCh: 833 - if selectedProfile().backend().supportsUnlock() { 834 - repoOp("Unlocking repository...", "unlock") 835 - } 836 - case <-mInit.ClickedCh: 837 - repoOp("Initializing repository...", "init") 838 - case <-mMount.ClickedCh: 839 - idx := getActiveProfile() 840 - prof := selectedProfile() 841 - if isProfileMounted(prof.profileKey()) { 842 - stopProfileMount(prof.profileKey()) 843 - } else { 844 - go startMount(prof.profileKey(), prof, mMount, func() { finishProfile(idx) }) 845 - } 846 - case <-mConsole.ClickedCh: 847 - cfg := loadConfig() 848 - openConsole(selectedProfile(), len(cfg.Profiles), cfg.GUI.Terminal, "") 849 - case <-mPreHook.ClickedCh: 850 - prof := selectedProfile() 851 - if prof.PreHook != "" { 852 - go func() { 853 - if err := runHook(prof.PreHook, prof); err != nil { 854 - notifyError("Pre-hook", "Pre-hook failed") 855 - } else { 856 - notifySuccess("Pre-hook") 857 - } 858 - }() 859 - } 860 - case <-mPostHook.ClickedCh: 861 - prof := selectedProfile() 862 - if prof.PostHook != "" { 863 - go func() { 864 - if err := runHook(prof.PostHook, prof); err != nil { 865 - notifyError("Post-hook", "Post-hook failed") 866 - } else { 867 - notifySuccess("Post-hook") 868 - } 869 - }() 870 - } 871 - case <-mDownload.ClickedCh: 872 - if !selfManagesBackend { 873 - if selectedProfile().backend() == BackendRustic { 874 - openFile("https://rustic.cli.rs/docs/installation.html") 875 - } else { 876 - openFile("https://restic.readthedocs.io/en/stable/020_installation.html") 877 - } 878 - } else if !isAnyBusy() { 879 - hideActions() 880 - go doDownload() 881 - } 882 - case <-mUpdate.ClickedCh: 883 - if selfManagesBackend && !isAnyBusy() { 884 - hideActions() 885 - go doDownload() 886 - } 887 - case <-mSettings.ClickedCh: 888 - if err := ensureConfigFile(); err != nil { 889 - log.Printf("config: %v", err) 890 - continue 891 - } 892 - openInEditor(configPath()) 893 - case <-mWebEditor.ClickedCh: 894 - if err := ensureConfigFile(); err != nil { 895 - log.Printf("config: %v", err) 896 - continue 897 - } 898 - url, err := ensureWebEditor() 899 - if err != nil { 900 - log.Printf("webeditor: %v", err) 901 - continue 902 - } 903 - openFile(url) 904 - case <-mEnv.ClickedCh: 905 - prof := selectedProfile() 906 - if err := ensureEnvFile(prof.EnvFile); err != nil { 907 - log.Printf("env: %v", err) 908 - continue 909 - } 910 - openInEditor(prof.EnvFile) 911 - case <-mFDA.ClickedCh: 912 - openFullDiskAccessSettings() 913 - case <-mFixPerms.ClickedCh: 914 - cfg := loadConfig() 915 - fixEnvFilePermissions(cfg.Profiles) 916 - if envFilesInsecure(cfg.Profiles) { 917 - mFixPerms.Show() 880 + case <-c.mConsole.ClickedCh: 881 + cfg := loadConfig() 882 + if _, prof, ok := c.activeProfile(cfg); ok { 883 + openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, "") 884 + } 885 + case <-c.mPreHook.ClickedCh: 886 + prof := c.selectedProfile() 887 + c.runManualHook(prof, prof.PreHook, "Pre-hook") 888 + case <-c.mPostHook.ClickedCh: 889 + prof := c.selectedProfile() 890 + c.runManualHook(prof, prof.PostHook, "Post-hook") 891 + case <-c.mDownload.ClickedCh: 892 + if !selfManagesBackend { 893 + if c.selectedProfile().backend() == BackendRustic { 894 + openFile("https://rustic.cli.rs/docs/installation.html") 918 895 } else { 919 - mFixPerms.Hide() 920 - } 921 - if !isAnyBusy() { 922 - applyConfig() 896 + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") 923 897 } 924 - case <-mLog.ClickedCh: 925 - cfg := loadConfig() 926 - openConsole(selectedProfile(), len(cfg.Profiles), cfg.GUI.Terminal, logPath()) 927 - case <-mFolder.ClickedCh: 928 - openFile(configDir()) 929 - case <-mAbout.ClickedCh: 930 - if v := latestKnownVersion.Load(); v != nil { 931 - if applyUpdate(*v) { 932 - sched.Stop() 933 - stopAllMounts() 934 - systray.Quit() 935 - } 936 - } else { 937 - openFile("https://tangled.org/devins.page/restray") 898 + } else if !isAnyBusy() { 899 + c.hideActions() 900 + go c.doDownload() 901 + } 902 + case <-c.mUpdate.ClickedCh: 903 + if selfManagesBackend && !isAnyBusy() { 904 + c.hideActions() 905 + go c.doDownload() 906 + } 907 + case <-c.mSettings.ClickedCh: 908 + if err := ensureConfigFile(); err != nil { 909 + log.Printf("config: %v", err) 910 + continue 911 + } 912 + openInEditor(configPath()) 913 + case <-c.mWebEditor.ClickedCh: 914 + if err := ensureConfigFile(); err != nil { 915 + log.Printf("config: %v", err) 916 + continue 917 + } 918 + url, err := ensureWebEditor() 919 + if err != nil { 920 + log.Printf("webeditor: %v", err) 921 + continue 922 + } 923 + openFile(url) 924 + case <-c.mEnv.ClickedCh: 925 + prof := c.selectedProfile() 926 + if err := ensureEnvFile(prof.EnvFile); err != nil { 927 + log.Printf("env: %v", err) 928 + continue 929 + } 930 + openInEditor(prof.EnvFile) 931 + case <-c.mFDA.ClickedCh: 932 + openFullDiskAccessSettings() 933 + case <-c.mFixPerms.ClickedCh: 934 + cfg := loadConfig() 935 + fixEnvFilePermissions(cfg.Profiles) 936 + if envFilesInsecure(cfg.Profiles) { 937 + c.mFixPerms.Show() 938 + } else { 939 + c.mFixPerms.Hide() 940 + } 941 + if !isAnyBusy() { 942 + c.applyConfig() 943 + } 944 + case <-c.mLog.ClickedCh: 945 + cfg := loadConfig() 946 + if _, prof, ok := c.activeProfile(cfg); ok { 947 + openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, logPath()) 948 + } 949 + case <-c.mFolder.ClickedCh: 950 + openFile(configDir()) 951 + case <-c.mAbout.ClickedCh: 952 + if v := latestKnownVersion.Load(); v != nil { 953 + if applyUpdate(*v) { 954 + c.stopScheduler() 955 + stopAllMounts() 956 + systray.Quit() 938 957 } 939 - case <-mQuit.ClickedCh: 940 - sched.Stop() 941 - stopAllMounts() 942 - systray.Quit() 958 + } else { 959 + openFile("https://tangled.org/devins.page/restray") 943 960 } 961 + case <-c.mQuit.ClickedCh: 962 + c.stopScheduler() 963 + stopAllMounts() 964 + systray.Quit() 944 965 } 945 - }() 946 - 947 - if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { 948 - startAppUpdateChecker(mAbout) 949 966 } 950 - 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 - }) 963 967 } 964 968 965 - func formatNextFor(sched *cron.Cron, ids []cron.EntryID) string { 966 - if len(ids) == 0 { 967 - return "" 969 + func (c *trayController) runManualHook(prof Profile, hook, name string) { 970 + if hook == "" { 971 + return 968 972 } 969 - want := make(map[cron.EntryID]bool, len(ids)) 970 - for _, id := range ids { 971 - want[id] = true 972 - } 973 - now := time.Now() 974 - var earliest time.Time 975 - for _, e := range sched.Entries() { 976 - if !want[e.ID] { 977 - continue 973 + go func() { 974 + if err := runHook(hook, prof); err != nil { 975 + notifyError(name, name+" failed") 976 + } else { 977 + notifySuccess(name) 978 978 } 979 - next := e.Schedule.Next(now) 980 - if earliest.IsZero() || next.Before(earliest) { 981 - earliest = next 982 - } 983 - } 984 - if earliest.IsZero() { 985 - return "" 979 + }() 980 + } 981 + 982 + func (c *trayController) startProfileSelectionHandlers() { 983 + for i := range c.profileItems { 984 + idx := i 985 + go func() { 986 + for range c.profileItems[idx].ClickedCh { 987 + cfg := loadConfig() 988 + if idx >= len(cfg.Profiles) { 989 + continue 990 + } 991 + setActiveProfileKey(cfg.Profiles[idx].profileKey()) 992 + c.markActiveProfile(idx, len(cfg.Profiles)) 993 + c.setProfileTitle(cfg, idx) 994 + c.applyProfileUI(cfg) 995 + } 996 + }() 986 997 } 987 - return humanize.Time(earliest) 988 998 } 989 999 990 1000 func formatLastBackup(last time.Time) string { ··· 993 1003 } 994 1004 return humanize.Time(last) 995 1005 } 996 - 997 - func onBatteryPower() bool { 998 - b, err := battery.Get(0) 999 - if _, ok := err.(battery.ErrFatal); ok { 1000 - return false 1001 - } 1002 - if b == nil { 1003 - return false 1004 - } 1005 - return b.State.Raw == battery.Discharging 1006 - } 1007 - 1008 - func skipForBattery(prof Profile) bool { 1009 - return !prof.Schedule.OnBattery && onBatteryPower() 1010 - }
+1 -1
justfile
··· 238 238 grep -n "$new" {{pkg}}/main.go flake.nix packaging/windows/installer.nsi packaging/darwin/Info.plist 239 239 240 240 clean: 241 - rm -rf {{bin}} {{dist}} result 241 + rm -rf {{bin}} {{dist}} result {{app}} {{app}}.exe