diff --git a/.gitignore b/.gitignore index aca7209..6ecc068 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ bin/ dist/ result -/Restray -/Restray.exe +/restray +/restray.exe /cmd/restray/restray_windows_*.syso diff --git a/cmd/restray/cli.go b/cmd/restray/cli.go index 3a322dd..ba6af40 100644 --- a/cmd/restray/cli.go +++ b/cmd/restray/cli.go @@ -160,8 +160,14 @@ func cliRunBackendWithRetry(prof Profile, args []string) error { } func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { - stdoutPipe, _ := cmd.StdoutPipe() - stderrPipe, _ := cmd.StderrPipe() + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return err + } if err := cmd.Start(); err != nil { return err } @@ -171,12 +177,17 @@ func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { go func(pipe io.Reader) { defer wg.Done() scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { logPrefixedLine(prof.displayName(), scanner.Text()) } + if err := scanner.Err(); err != nil { + log.Printf("[%s] reading command output: %v", prof.displayName(), err) + _, _ = io.Copy(io.Discard, pipe) + } }(pipe) } - err := cmd.Wait() + err = cmd.Wait() wg.Wait() return err } @@ -281,7 +292,7 @@ func cliMount(prof Profile) error { } func cliSchedule(prof Profile) error { - if errMsg := prof.profileError(); errMsg != "" { + if errMsg := prof.scheduleError(); errMsg != "" { return cli.Exit("error: "+errMsg, 1) } p, _ := findBackend(prof) @@ -368,35 +379,17 @@ func buildDaemonSchedule() (*cron.Cron, error) { } key := prof.profileKey() name := prof.displayName() - if errMsg := prof.scheduleConfigError(); errMsg != "" { + if errMsg := prof.scheduleDefinitionError(); errMsg != "" { log.Printf("[%s] skipping schedule: %s", name, errMsg) continue } _, err := sched.AddFunc(prof.Schedule.Cron, func() { - current := loadConfig() - _, prof, err := resolveProfileIndex(current, key) - if err != nil { - log.Printf("[%s] skipping scheduled run: %v", name, err) - return - } - if skipForBattery(prof) { - log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) - return - } - if !acquireProfile(key) { - log.Printf("[%s] skipping scheduled run: profile already busy", prof.displayName()) - return - } - defer releaseProfile(key) - defer func() { - if r := recover(); r != nil { - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) + runScheduleCallback(key, nil, func(_ Config, _ int, prof Profile) { + log.Printf("[%s] starting scheduled run", prof.displayName()) + if err := cliSchedule(prof); err != nil { + log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err) } - }() - log.Printf("[%s] starting scheduled run", prof.displayName()) - if err := cliSchedule(prof); err != nil { - log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err) - } + }) }) if err != nil { log.Printf("[%s] skipping schedule: invalid cron expression %q: %v", name, prof.Schedule.Cron, err) diff --git a/cmd/restray/config.go b/cmd/restray/config.go index 0014c14..8e1e1fc 100644 --- a/cmd/restray/config.go +++ b/cmd/restray/config.go @@ -41,31 +41,23 @@ type Backup struct { ArgsScheduled []string `toml:"args_scheduled"` } -type Prune struct { - Args []string `toml:"args"` -} - -type Check struct { - Args []string `toml:"args"` -} - -type Mount struct { +type OperationOptions struct { Args []string `toml:"args"` } type Profile struct { - Name string `toml:"name"` - Backend string `toml:"backend"` - EnvFile string `toml:"env_file"` - RcloneConfigFile string `toml:"rclone_config_file"` - RetryLock string `toml:"retry_lock"` - PreHook string `toml:"pre_hook"` - PostHook string `toml:"post_hook"` - Schedule Schedule `toml:"schedule"` - Backup Backup `toml:"backup"` - Prune Prune `toml:"prune"` - Check Check `toml:"check"` - Mount Mount `toml:"mount"` + Name string `toml:"name"` + Backend string `toml:"backend"` + EnvFile string `toml:"env_file"` + RcloneConfigFile string `toml:"rclone_config_file"` + RetryLock string `toml:"retry_lock"` + PreHook string `toml:"pre_hook"` + PostHook string `toml:"post_hook"` + Schedule Schedule `toml:"schedule"` + Backup Backup `toml:"backup"` + Prune OperationOptions `toml:"prune"` + Check OperationOptions `toml:"check"` + Mount OperationOptions `toml:"mount"` } func (prof Profile) profileKey() stateKey { @@ -133,17 +125,17 @@ func parseEnvFile(path string) (map[string]string, error) { return env, nil } -func (prof Profile) scheduleConfigError() string { +func (prof Profile) baseConfigError() string { if prof.backend() == "" { return "Unsupported backend: " + prof.Backend } - if prof.Schedule.BackupEnabled() && len(prof.Backup.Paths) == 0 { - return "No paths configured" - } return "" } -func (prof Profile) environmentError() string { +func (prof Profile) repositoryError() string { + if err := prof.baseConfigError(); err != "" { + return err + } vars, err := parseEnvFile(prof.EnvFile) if err != nil { return "Env file not available" @@ -151,14 +143,20 @@ func (prof Profile) environmentError() string { return backendEnvError(prof.backend(), vars) } -func (prof Profile) profileError() string { - if err := prof.scheduleConfigError(); err != "" { +func (prof Profile) scheduleError() string { + if err := prof.scheduleDefinitionError(); err != "" { + return err + } + return prof.repositoryError() +} +func (prof Profile) scheduleDefinitionError() string { + if err := prof.baseConfigError(); err != "" { return err } - if len(prof.Backup.Paths) == 0 { + if prof.Schedule.BackupEnabled() && len(prof.Backup.Paths) == 0 { return "No paths configured" } - return prof.environmentError() + return "" } func (prof Profile) displayName() string { diff --git a/cmd/restray/config_test.go b/cmd/restray/config_test.go index 211c62a..c8baf5d 100644 --- a/cmd/restray/config_test.go +++ b/cmd/restray/config_test.go @@ -23,13 +23,81 @@ func TestParseEnvFile(t *testing.T) { } } +func TestProfileRepositoryError(t *testing.T) { + tests := []struct { + name string + backend string + env string + missingEnv bool + want string + }{ + {name: "valid restic environment", backend: "restic", env: "RESTIC_REPOSITORY=/repo\nRESTIC_PASSWORD=secret\n"}, + {name: "valid rustic environment", backend: "rustic", env: "RUSTIC_REPOSITORY=/repo\n"}, + {name: "missing env file", backend: "restic", missingEnv: true, want: "Env file not available"}, + + {name: "missing repository", backend: "restic", env: "RESTIC_PASSWORD=secret\n", want: "RESTIC_REPOSITORY not set in env file"}, + {name: "missing restic password", backend: "restic", env: "RESTIC_REPOSITORY=/repo\n", want: "No password set in env file"}, + {name: "unsupported backend", backend: "borg", env: "BORG_REPOSITORY=/repo\n", want: "Unsupported backend: borg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envFile := filepath.Join(t.TempDir(), "profile.env") + if !tt.missingEnv { + if err := os.WriteFile(envFile, []byte(tt.env), 0600); err != nil { + t.Fatal(err) + } + } + prof := Profile{Backend: tt.backend, EnvFile: envFile} + if got := prof.repositoryError(); got != tt.want { + t.Fatalf("repositoryError() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestProfileScheduleError(t *testing.T) { + enabled := true + disabled := false + tests := []struct { + name string + backup *bool + paths []string + prune bool + want string + }{ + {name: "backup enabled by default without paths", want: "No paths configured"}, + {name: "backup explicitly enabled without paths", backup: &enabled, want: "No paths configured"}, + {name: "backup enabled with paths", paths: []string{"/data"}}, + {name: "prune only without paths", backup: &disabled, prune: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envFile := filepath.Join(t.TempDir(), "profile.env") + if err := os.WriteFile(envFile, []byte("RESTIC_REPOSITORY=/repo\nRESTIC_PASSWORD=secret\n"), 0600); err != nil { + t.Fatal(err) + } + prof := Profile{ + Backend: "restic", + EnvFile: envFile, + Schedule: Schedule{Backup: tt.backup, Prune: tt.prune}, + Backup: Backup{Paths: tt.paths}, + } + if got := prof.scheduleError(); got != tt.want { + t.Fatalf("scheduleError() = %q, want %q", got, tt.want) + } + }) + } +} + func TestLoadConfig(t *testing.T) { dir := t.TempDir() old := configDirOverride configDirOverride = dir t.Cleanup(func() { configDirOverride = old }) - contents := "[[profiles]]\nname = 'work'\n[profiles.backup]\npaths = ['/home/me']\n" + 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" if err := os.WriteFile(configPath(), []byte(contents), 0600); err != nil { t.Fatal(err) } @@ -44,6 +112,15 @@ func TestLoadConfig(t *testing.T) { if !reflect.DeepEqual(p.Backup.Args, []string{"--exclude-caches", "--exclude", "*.tmp"}) { t.Fatalf("backup defaults = %#v", p.Backup.Args) } + if !reflect.DeepEqual(p.Prune.Args, []string{"--keep-last", "2"}) { + t.Fatalf("prune options = %#v", p.Prune.Args) + } + if !reflect.DeepEqual(p.Check.Args, []string{"--read-data"}) { + t.Fatalf("check options = %#v", p.Check.Args) + } + if !reflect.DeepEqual(p.Mount.Args, []string{"--allow-other"}) { + t.Fatalf("mount options = %#v", p.Mount.Args) + } if err := os.WriteFile(configPath(), []byte("[[profiles]]\nname = 'same'\n[[profiles]]\nname = 'same'\n"), 0600); err != nil { t.Fatal(err) diff --git a/cmd/restray/operations.go b/cmd/restray/operations.go index 44e6d76..3284b5a 100644 --- a/cmd/restray/operations.go +++ b/cmd/restray/operations.go @@ -11,7 +11,6 @@ import ( "path/filepath" "runtime" "strings" - "sync" "time" "fyne.io/systray" @@ -19,14 +18,30 @@ import ( "github.com/gen2brain/beeep" ) +type backupStatus struct { + Type string `json:"message_type"` + Percent float64 `json:"percent_done"` + Total uint64 `json:"total_bytes"` + Bytes uint64 `json:"bytes_done"` + TotalFiles uint64 `json:"total_files"` + FilesDone uint64 `json:"files_done"` +} + +func parseBackupStatus(line string) (backupStatus, bool) { + var status backupStatus + if json.Unmarshal([]byte(line), &status) != nil || status.Type != "status" { + return backupStatus{}, false + } + return status, true +} + type lastLineLogger struct { - mu sync.Mutex last string + done chan struct{} } -func (s *lastLineLogger) lastLine() string { - s.mu.Lock() - defer s.mu.Unlock() +func (s *lastLineLogger) wait() string { + <-s.done return s.last } @@ -43,19 +58,23 @@ func logPrefixedOutput(name, out string) { } func streamLogLines(pipe io.Reader, name string, onLine func(string)) *lastLineLogger { - sl := &lastLineLogger{} + sl := &lastLineLogger{done: make(chan struct{})} go func() { + defer close(sl.done) scanner := bufio.NewScanner(pipe) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { line := scanner.Text() logPrefixedLine(name, line) - sl.mu.Lock() sl.last = line - sl.mu.Unlock() if onLine != nil { onLine(line) } } + if err := scanner.Err(); err != nil { + log.Printf("[%s] reading command output: %v", name, err) + _, _ = io.Copy(io.Discard, pipe) + } }() return sl } @@ -72,7 +91,10 @@ func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args . cmd := backendCmd(prof, args...) var stdout strings.Builder cmd.Stdout = &stdout - stderrPipe, _ := cmd.StderrPipe() + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return err.Error(), err + } setProfileBusyCmd(key, cmd) if err := cmd.Start(); err != nil { @@ -81,7 +103,8 @@ func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args . } sl := streamStderr(stderrPipe, prof, mStatus) - err := cmd.Wait() + err = cmd.Wait() + lastStderr := sl.wait() if out := stdout.String(); out != "" { logPrefixedOutput(prof.displayName(), out) } @@ -89,7 +112,7 @@ func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args . log.Printf("[%s] done (%s): %s", prof.displayName(), prof.backendName(), args[0]) return "", nil } - msg, code := classifyBackendError(prof, err, sl.lastLine()) + msg, code := classifyBackendError(prof, err, lastStderr) if msg != "" { mStatus.SetTitle(msg) } @@ -129,8 +152,19 @@ func runBackend(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...st func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int, error) { cmd := backendCmd(prof, args...) - stdoutPipe, _ := cmd.StdoutPipe() - stderrPipe, _ := cmd.StderrPipe() + if prof.backend() == BackendRestic { + cmd.Env = setEnv(cmd.Env, "RESTIC_PROGRESS_FPS", "4") + } + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + setProfileFailed(key, err.Error()) + return false, -1, err + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + setProfileFailed(key, err.Error()) + return false, -1, err + } setProfileBusyCmd(key, cmd) if err := cmd.Start(); err != nil { @@ -146,39 +180,31 @@ func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []s for scanner.Scan() { line := scanner.Text() - var msg struct { - Type string `json:"message_type"` - Percent float64 `json:"percent_done"` - Total uint64 `json:"total_bytes"` - Bytes uint64 `json:"bytes_done"` - TotalFiles uint64 `json:"total_files"` - FilesDone uint64 `json:"files_done"` - } - if json.Unmarshal([]byte(line), &msg) != nil || msg.Type == "" { + status, ok := parseBackupStatus(line) + if !ok { logPrefixedLine(prof.displayName(), line) continue } - if msg.Type == "status" { - switch { - case msg.Total > 0: - mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s", - int(msg.Percent*100), humanize.Bytes(msg.Bytes), humanize.Bytes(msg.Total))) - case msg.TotalFiles > 0: - mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files", - msg.FilesDone, msg.TotalFiles)) - default: - mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", msg.FilesDone)) - } - } else { - logPrefixedLine(prof.displayName(), line) + switch { + case status.Total > 0: + mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s", + int(status.Percent*100), humanize.Bytes(status.Bytes), humanize.Bytes(status.Total))) + case status.TotalFiles > 0: + mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files", + status.FilesDone, status.TotalFiles)) + default: + mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", status.FilesDone)) } } if err := scanner.Err(); err != nil { log.Printf("[%s] reading backup output: %v", prof.displayName(), err) + _, _ = io.Copy(io.Discard, stdoutPipe) } - if err := cmd.Wait(); err != nil { - msg, code := classifyBackendError(prof, err, sl.lastLine()) + err = cmd.Wait() + lastStderr := sl.wait() + if err != nil { + msg, code := classifyBackendError(prof, err, lastStderr) log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) if prof.backend() == BackendRestic && code == 3 { log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) @@ -351,10 +377,14 @@ func runScheduled(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone f if !acquireProfile(key) { return } - setProfileFailed(key, "") defer onDone() defer releaseProfile(key) - if errMsg := prof.profileError(); errMsg != "" { + runScheduledAcquired(key, mStatus, prof) +} + +func runScheduledAcquired(key stateKey, mStatus prefixedMenuItem, prof Profile) { + setProfileFailed(key, "") + if errMsg := prof.scheduleError(); errMsg != "" { mStatus.SetTitle(errMsg) setProfileFailed(key, errMsg) notifyError("Schedule", errMsg) diff --git a/cmd/restray/operations_test.go b/cmd/restray/operations_test.go index 835db7e..27cff16 100644 --- a/cmd/restray/operations_test.go +++ b/cmd/restray/operations_test.go @@ -5,6 +5,20 @@ import ( "testing" ) +func TestParseBackupStatus(t *testing.T) { + line := `{"message_type":"status","percent_done":0.5,"total_bytes":1000,"bytes_done":500,"total_files":10,"files_done":5}` + want := backupStatus{Type: "status", Percent: 0.5, Total: 1000, Bytes: 500, TotalFiles: 10, FilesDone: 5} + if got, ok := parseBackupStatus(line); !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("parseBackupStatus() = %#v, %v; want %#v, true", got, ok, want) + } + if _, ok := parseBackupStatus(`{"message_type":"summary"}`); ok { + t.Fatal("expected summary message to be ignored") + } + if _, ok := parseBackupStatus("not json"); ok { + t.Fatal("expected malformed status to be ignored") + } +} + func TestBackupArgs(t *testing.T) { prof := Profile{ Backend: "restic", @@ -28,7 +42,7 @@ func TestBackupArgs(t *testing.T) { } func TestRunScheduledOperations(t *testing.T) { - prof := Profile{Schedule: Schedule{Prune: true, Check: true}, Prune: Prune{Args: []string{"--keep-last", "4"}}} + prof := Profile{Schedule: Schedule{Prune: true, Check: true}, Prune: OperationOptions{Args: []string{"--keep-last", "4"}}} var ran []string failed, msg := runScheduledOperations(prof, nil, func() (bool, string) { ran = append(ran, "backup"); return false, "backup failed" }, diff --git a/cmd/restray/schedule.go b/cmd/restray/schedule.go new file mode 100644 index 0000000..3ce7afb --- /dev/null +++ b/cmd/restray/schedule.go @@ -0,0 +1,68 @@ +package main + +import ( + "log" + "strings" + + "github.com/distatus/battery" + hcron "github.com/lnquy/cron" +) + +var cronDescriptor, _ = hcron.NewDescriptor() + +func describeCron(expr string) string { + desc, err := cronDescriptor.ToDescription(expr, hcron.Locale_en) + if err != nil { + return expr + } + desc = strings.TrimSpace(desc) + if len(desc) > 0 { + desc = strings.ToLower(desc[:1]) + desc[1:] + } + return desc +} + +func onBatteryPower() bool { + b, err := battery.Get(0) + if _, ok := err.(battery.ErrFatal); ok { + return false + } + if b == nil { + return false + } + return b.State.Raw == battery.Discharging +} + +func schedulePausedForBattery(prof Profile) bool { + return !prof.Schedule.OnBattery && onBatteryPower() +} + +func runScheduleCallback(key stateKey, onDone func(), run func(Config, int, Profile)) { + name := string(key) + defer func() { + if r := recover(); r != nil { + log.Printf("[%s] scheduled run panicked: %v", name, r) + } + }() + + cfg := loadConfig() + idx, prof, err := resolveProfileIndex(cfg, key) + if err != nil { + log.Printf("[%s] skipping scheduled run: %v", name, err) + return + } + name = prof.displayName() + if schedulePausedForBattery(prof) { + log.Printf("[%s] skipping scheduled run: on battery power", name) + return + } + if !acquireProfile(key) { + log.Printf("[%s] skipping scheduled run: profile already busy", name) + return + } + if onDone != nil { + defer onDone() + } + defer releaseProfile(key) + run(cfg, idx, prof) +} diff --git a/cmd/restray/schedule_test.go b/cmd/restray/schedule_test.go new file mode 100644 index 0000000..573222a --- /dev/null +++ b/cmd/restray/schedule_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "os" + "testing" +) + +func writeScheduleConfig(t *testing.T, contents string) { + t.Helper() + oldConfigDir := configDirOverride + configDirOverride = t.TempDir() + t.Cleanup(func() { configDirOverride = oldConfigDir }) + if err := os.WriteFile(configPath(), []byte(contents), 0600); err != nil { + t.Fatal(err) + } +} + +func setupScheduleCallbackTest(t *testing.T) stateKey { + t.Helper() + writeScheduleConfig(t, "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\non_battery = true\nbackup = false\n") + resetBusyState(t) + return stateKey("scheduled") +} + +func TestRunScheduleCallback(t *testing.T) { + key := setupScheduleCallbackTest(t) + var ran bool + doneAfterRelease := false + runScheduleCallback(key, func() { + doneAfterRelease = !isProfileBusy(key) + }, func(_ Config, _ int, prof Profile) { + ran = prof.profileKey() == key + if !isProfileBusy(key) { + t.Error("profile was not busy during callback") + } + }) + if !ran { + t.Fatal("schedule callback did not run") + } + if !doneAfterRelease { + t.Fatal("completion callback ran before profile release") + } +} + +func TestRunScheduleCallbackGuards(t *testing.T) { + t.Run("missing profile", func(t *testing.T) { + setupScheduleCallbackTest(t) + ran := false + runScheduleCallback("missing", nil, func(Config, int, Profile) { ran = true }) + if ran { + t.Fatal("callback ran for missing profile") + } + }) + + t.Run("busy", func(t *testing.T) { + key := setupScheduleCallbackTest(t) + if !acquireProfile(key) { + t.Fatal("could not mark profile busy") + } + defer releaseProfile(key) + ran := false + runScheduleCallback(key, nil, func(Config, int, Profile) { ran = true }) + if ran { + t.Fatal("callback ran for busy profile") + } + if !isProfileBusy(key) { + t.Fatal("callback released another operation's profile ownership") + } + }) +} + +func TestBuildDaemonScheduleValidation(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + }{ + { + name: "missing environment", + config: "[[profiles]]\nname = 'scheduled'\nenv_file = '/missing/profile.env'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", + }, + { + name: "unsupported backend", + config: "[[profiles]]\nname = 'scheduled'\nbackend = 'borg'\n[profiles.schedule]\ncron = '0 * * * *'\n[profiles.backup]\npaths = ['/data']\n", + wantErr: true, + }, + { + name: "missing backup paths", + config: "[[profiles]]\nname = 'scheduled'\n[profiles.schedule]\ncron = '0 * * * *'\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeScheduleConfig(t, tt.config) + sched, err := buildDaemonSchedule() + if (err != nil) != tt.wantErr { + t.Fatalf("buildDaemonSchedule() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && len(sched.Entries()) != 1 { + t.Fatalf("schedule entries = %d, want 1", len(sched.Entries())) + } + }) + } +} + +func TestRunScheduleCallbackPanicCleanup(t *testing.T) { + key := setupScheduleCallbackTest(t) + done := false + runScheduleCallback(key, func() { done = true }, func(Config, int, Profile) { + panic("boom") + }) + if isProfileBusy(key) { + t.Fatal("profile remained busy after panic") + } + if !done { + t.Fatal("completion callback did not run after panic") + } +} diff --git a/cmd/restray/state.go b/cmd/restray/state.go index 8b2603b..dda09de 100644 --- a/cmd/restray/state.go +++ b/cmd/restray/state.go @@ -4,10 +4,20 @@ import ( "os/exec" "sync" "time" + + "github.com/robfig/cron/v3" ) type stateKey string +type profileState struct { + errMsg string + repoErr string + scheduleErr string + needsInit bool + entryID cron.EntryID +} + type appState struct { mu sync.Mutex busyProfiles map[stateKey]*exec.Cmd @@ -17,6 +27,8 @@ type appState struct { notifications string failStatus map[stateKey]string lastBackup map[stateKey]time.Time + activeProfile stateKey + profileStates map[stateKey]profileState } var state = appState{ @@ -25,6 +37,64 @@ var state = appState{ mountStopping: make(map[stateKey]bool), failStatus: make(map[stateKey]string), lastBackup: make(map[stateKey]time.Time), + profileStates: make(map[stateKey]profileState), +} + +func resetProfileState(keys []stateKey) stateKey { + state.mu.Lock() + defer state.mu.Unlock() + + profiles := make(map[stateKey]profileState, len(keys)) + for _, key := range keys { + profiles[key] = state.profileStates[key] + } + state.profileStates = profiles + if _, ok := profiles[state.activeProfile]; !ok { + state.activeProfile = "" + if len(keys) > 0 { + state.activeProfile = keys[0] + } + } + return state.activeProfile +} + +func activeProfileKey() stateKey { + state.mu.Lock() + defer state.mu.Unlock() + return state.activeProfile +} + +func setActiveProfileKey(key stateKey) { + state.mu.Lock() + if _, ok := state.profileStates[key]; ok { + state.activeProfile = key + } + state.mu.Unlock() +} + +func getProfileState(key stateKey) (profileState, bool) { + state.mu.Lock() + defer state.mu.Unlock() + ps, ok := state.profileStates[key] + return ps, ok +} + +func setProfileProbeState(key stateKey, probe profileState) { + state.mu.Lock() + if current, ok := state.profileStates[key]; ok { + probe.entryID = current.entryID + state.profileStates[key] = probe + } + state.mu.Unlock() +} + +func setProfileCronEntry(key stateKey, entryID cron.EntryID) { + state.mu.Lock() + if current, ok := state.profileStates[key]; ok { + current.entryID = entryID + state.profileStates[key] = current + } + state.mu.Unlock() } func setLastBackup(key stateKey, t time.Time) { @@ -94,8 +164,9 @@ func setProfileBusyCmd(key stateKey, cmd *exec.Cmd) { func cancelProfile(key stateKey) { state.mu.Lock() - defer state.mu.Unlock() - if cmd := state.busyProfiles[key]; cmd != nil && cmd.Process != nil { + cmd := state.busyProfiles[key] + state.mu.Unlock() + if cmd != nil && cmd.Process != nil { interruptProcess(cmd.Process) } } diff --git a/cmd/restray/state_test.go b/cmd/restray/state_test.go new file mode 100644 index 0000000..87ee8a1 --- /dev/null +++ b/cmd/restray/state_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "os" + "os/exec" + "testing" + "time" +) + +const busyTransitionGuard stateKey = "busy-transition-guard" + +func resetProfileStateForTest(t *testing.T) { + t.Helper() + state.mu.Lock() + oldActive := state.activeProfile + oldProfiles := state.profileStates + state.activeProfile = "" + state.profileStates = make(map[stateKey]profileState) + state.mu.Unlock() + t.Cleanup(func() { + state.mu.Lock() + state.activeProfile = oldActive + state.profileStates = oldProfiles + state.mu.Unlock() + }) +} + +func TestProfileStatePartialUpdates(t *testing.T) { + resetProfileStateForTest(t) + key := stateKey("alpha") + resetProfileState([]stateKey{key}) + setProfileCronEntry(key, 42) + want := profileState{errMsg: "config", repoErr: "repository", scheduleErr: "schedule", needsInit: true, entryID: 42} + setProfileProbeState(key, want) + + got, ok := getProfileState(key) + if !ok { + t.Fatal("profile state missing") + } + if got != want { + t.Fatalf("probe update = %+v, want %+v", got, want) + } + + setProfileCronEntry(key, 73) + want.entryID = 73 + got, _ = getProfileState(key) + if got != want { + t.Fatalf("cron update = %+v, want %+v", got, want) + } +} + +func TestProfileStateSurvivesReorder(t *testing.T) { + resetProfileStateForTest(t) + alpha := stateKey("alpha") + beta := stateKey("beta") + resetProfileState([]stateKey{alpha, beta}) + setProfileProbeState(alpha, profileState{errMsg: "alpha error"}) + setProfileProbeState(beta, profileState{repoErr: "beta error", needsInit: true}) + setProfileCronEntry(alpha, 11) + setProfileCronEntry(beta, 22) + setActiveProfileKey(beta) + + if active := resetProfileState([]stateKey{beta, alpha}); active != beta { + t.Fatalf("active profile after reorder = %q, want %q", active, beta) + } + alphaState, _ := getProfileState(alpha) + betaState, _ := getProfileState(beta) + if alphaState.errMsg != "alpha error" || alphaState.entryID != 11 { + t.Fatalf("alpha state moved during reorder: %+v", alphaState) + } + if betaState.repoErr != "beta error" || !betaState.needsInit || betaState.entryID != 22 { + t.Fatalf("beta state moved during reorder: %+v", betaState) + } +} + +func TestActiveProfileFallsBackAfterRemoval(t *testing.T) { + resetProfileStateForTest(t) + alpha := stateKey("alpha") + beta := stateKey("beta") + resetProfileState([]stateKey{alpha, beta}) + setActiveProfileKey(beta) + + if active := resetProfileState([]stateKey{alpha}); active != alpha { + t.Fatalf("active profile after removal = %q, want %q", active, alpha) + } + if _, ok := getProfileState(beta); ok { + t.Fatal("removed profile state was retained") + } + if active := resetProfileState(nil); active != "" { + t.Fatalf("active profile with no profiles = %q, want empty", active) + } +} + +func resetBusyState(t *testing.T) { + t.Helper() + + state.mu.Lock() + oldBusyProfiles := state.busyProfiles + oldOnBusyChanged := state.onBusyChanged + state.busyProfiles = map[stateKey]*exec.Cmd{busyTransitionGuard: nil} + state.onBusyChanged = nil + state.mu.Unlock() + + t.Cleanup(func() { + state.mu.Lock() + state.busyProfiles = oldBusyProfiles + state.onBusyChanged = oldOnBusyChanged + state.mu.Unlock() + }) +} + +func TestCancelProfileInterruptsCommand(t *testing.T) { + if os.Getenv("RESTRAY_STATE_TEST_HELPER") == "1" { + time.Sleep(30 * time.Second) + return + } + + resetBusyState(t) + key := stateKey("cancel") + cmd := exec.Command(os.Args[0], "-test.run=^TestCancelProfileInterruptsCommand$") + cmd.Env = append(os.Environ(), "RESTRAY_STATE_TEST_HELPER=1") + if err := cmd.Start(); err != nil { + t.Fatalf("start helper process: %v", err) + } + wait := make(chan error, 1) + go func() { wait <- cmd.Wait() }() + waited := false + t.Cleanup(func() { + if !waited { + _ = cmd.Process.Kill() + <-wait + } + }) + + setProfileBusyCmd(key, cmd) + cancelProfile(key) + + select { + case err := <-wait: + waited = true + if err == nil { + t.Fatal("helper process exited successfully; want interruption") + } + case <-time.After(5 * time.Second): + t.Fatal("busy command did not stop after cancellation") + } + + releaseProfile(key) +} diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go index 338bf33..8d79b28 100644 --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -9,9 +9,7 @@ import ( "time" "fyne.io/systray" - "github.com/distatus/battery" "github.com/dustin/go-humanize" - hcron "github.com/lnquy/cron" "github.com/robfig/cron/v3" ) @@ -20,20 +18,6 @@ const ( maxMenuWidth = 64 ) -var cronDescriptor, _ = hcron.NewDescriptor() - -func describeCron(expr string) string { - desc, err := cronDescriptor.ToDescription(expr, hcron.Locale_en) - if err != nil { - return expr - } - desc = strings.TrimSpace(desc) - if len(desc) > 0 { - desc = strings.ToLower(desc[:1]) + desc[1:] - } - return desc -} - type prefixedMenuItem struct { *systray.MenuItem prefix string @@ -47,25 +31,6 @@ func (p prefixedMenuItem) SetTitle(title string) { p.MenuItem.SetTitle(title) } -type profileState struct { - errMsg string - repoErr string - needsInit bool -} - -var ( - profileMu sync.Mutex - activeProfile int - profileStates []profileState - profileEntryIDs map[int][]cron.EntryID -) - -func getActiveProfile() int { - profileMu.Lock() - defer profileMu.Unlock() - return activeProfile -} - func setProfileFailed(key stateKey, status string) { state.mu.Lock() if status == "" { @@ -84,15 +49,12 @@ func getProfileFailStatus(key stateKey) string { func anyError() bool { state.mu.Lock() - hasFail := len(state.failStatus) > 0 - state.mu.Unlock() - if hasFail { + defer state.mu.Unlock() + if len(state.failStatus) > 0 { return true } - profileMu.Lock() - defer profileMu.Unlock() - for _, p := range profileStates { - if p.errMsg != "" || p.repoErr != "" { + for _, ps := range state.profileStates { + if ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" { return true } } @@ -107,15 +69,12 @@ func profilePrefix(cfg Config, prof Profile) string { } func unreachableRecovered(cfg Config) bool { - profileMu.Lock() var unreachable []Profile - for i, p := range profileStates { - if p.errMsg == "" && p.repoErr != "" && i < len(cfg.Profiles) { - unreachable = append(unreachable, cfg.Profiles[i]) + for _, prof := range cfg.Profiles { + if ps, ok := getProfileState(prof.profileKey()); ok && ps.errMsg == "" && ps.repoErr != "" { + unreachable = append(unreachable, prof) } } - profileMu.Unlock() - for _, prof := range unreachable { if repoStatus(prof).errMsg == "" { return true @@ -187,17 +146,6 @@ func startTrayStatusMonitor(applyConfig func(), updateStatus, applyProfileUI fun }() } -func startProfileSelectionHandlers(items [maxProfiles]*systray.MenuItem, selectProfile func(int)) { - for i := range items { - idx := i - go func() { - for range items[idx].ClickedCh { - selectProfile(idx) - } - }() - } -} - func startAppUpdateChecker(item *systray.MenuItem) { go func() { check := func() { @@ -220,771 +168,833 @@ func startAppUpdateChecker(item *systray.MenuItem) { }() } -func onReady() { - firstLaunch := !fileExists(configPath()) +type trayController struct { + mGlobalStatus prefixedMenuItem + mStatusItems [maxProfiles]*systray.MenuItem + mProfile *systray.MenuItem + profileItems [maxProfiles]*systray.MenuItem + mDownload *systray.MenuItem + mUpdate *systray.MenuItem + mSchedule *systray.MenuItem + mCancel *systray.MenuItem + mInit *systray.MenuItem + mRepo *systray.MenuItem + mBackup *systray.MenuItem + mPrune *systray.MenuItem + mCheck *systray.MenuItem + mUnlock *systray.MenuItem + mMount *systray.MenuItem + mConsole *systray.MenuItem + mPreHook *systray.MenuItem + mPostHook *systray.MenuItem + + mFDA *systray.MenuItem + mWebEditor *systray.MenuItem + mSettings *systray.MenuItem + mEnv *systray.MenuItem + mFixPerms *systray.MenuItem + mLog *systray.MenuItem + mFolder *systray.MenuItem + mAbout *systray.MenuItem + mQuit *systray.MenuItem + + applyMu sync.Mutex + pendingFullApply bool + schedulerMu sync.RWMutex + scheduler *cron.Cron +} + +func newTrayController() *trayController { + c := &trayController{} applyIconMode(loadConfig().GUI.Icon) setIconAnimated("idle") systray.SetTooltip("Restray") - mGlobalStatus := prefixedMenuItem{systray.AddMenuItem("", ""), ""} - mGlobalStatus.Disable() - mGlobalStatus.Hide() - var mStatusItems [maxProfiles]*systray.MenuItem - for i := range mStatusItems { - mStatusItems[i] = systray.AddMenuItem("", "") - mStatusItems[i].Disable() - mStatusItems[i].Hide() + c.mGlobalStatus = prefixedMenuItem{systray.AddMenuItem("", ""), ""} + c.mGlobalStatus.Disable() + c.mGlobalStatus.Hide() + for i := range c.mStatusItems { + c.mStatusItems[i] = systray.AddMenuItem("", "") + c.mStatusItems[i].Disable() + c.mStatusItems[i].Hide() } systray.AddSeparator() - mProfile := systray.AddMenuItem("Profile", "") - mProfile.Disable() - var profileItems [maxProfiles]*systray.MenuItem - for i := range profileItems { - profileItems[i] = mProfile.AddSubMenuItem("", "") - profileItems[i].Hide() + c.mProfile = systray.AddMenuItem("Profile", "") + c.mProfile.Disable() + for i := range c.profileItems { + c.profileItems[i] = c.mProfile.AddSubMenuItem("", "") + c.profileItems[i].Hide() + } + + c.mDownload = systray.AddMenuItem("Install Backend", "Install selected backup backend") + c.mDownload.Hide() + c.mUpdate = systray.AddMenuItem("", "Update backend binary") + c.mUpdate.Hide() + c.mSchedule = systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") + c.mSchedule.Hide() + c.mCancel = systray.AddMenuItem("Cancel Operation", "Cancel running operation") + c.mCancel.Hide() + c.mInit = systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") + c.mInit.Hide() + c.mRepo = systray.AddMenuItem("Operations", "Repository operations") + c.mRepo.Disable() + c.mBackup = c.mRepo.AddSubMenuItem("Backup", "Run a backup") + c.mBackup.Disable() + c.mPrune = c.mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") + c.mPrune.Disable() + c.mCheck = c.mRepo.AddSubMenuItem("Check", "Verify repository integrity") + c.mCheck.Disable() + c.mUnlock = c.mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") + c.mUnlock.Disable() + c.mMount = c.mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") + c.mConsole = c.mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") + c.mPreHook = c.mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") + c.mPreHook.Disable() + c.mPostHook = c.mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") + c.mPostHook.Disable() + systray.AddSeparator() + configure := systray.AddMenuItem("Configure", "Restray settings") + c.mFDA = configure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") + c.mFDA.Hide() + c.mWebEditor = configure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") + c.mSettings = configure.AddSubMenuItem("Edit Config File", "Open config file in editor") + c.mEnv = configure.AddSubMenuItem("Edit Env File", "Open env file in editor") + c.mFixPerms = configure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") + c.mFixPerms.Hide() + c.mLog = configure.AddSubMenuItem("View Log", "Open log file in editor") + c.mFolder = configure.AddSubMenuItem("Open Folder", "Open folder in file manager") + c.mAbout = configure.AddSubMenuItem("Restray v"+version, "Open repository in browser") + systray.AddSeparator() + c.mQuit = systray.AddMenuItem("Quit", "Quit Restray") + return c +} + +func onReady() { + newTrayController().start(!fileExists(configPath())) +} + +func (c *trayController) start(firstLaunch bool) { + state.mu.Lock() + state.onBusyChanged = c.onBusyChanged + state.mu.Unlock() + go c.applyConfig() + if firstLaunch && needsFullDiskAccess() { + go promptFullDiskAccess() } + watchConfig(c.applyConfig) - setProfileTitle := func(cfg Config, idx int) { - if idx < len(cfg.Profiles) { - mProfile.SetTitle(cfg.Profiles[idx].displayName()) - } - if len(cfg.Profiles) > 1 { - mProfile.Enable() - } else { - mProfile.Disable() - } + startTrayBackendMonitor(c.selectedProfile, c.applyConfig, c.mUpdate, c.mGlobalStatus) + startTrayStatusMonitor(c.applyConfig, c.updateAllStatusItems, c.applyProfileUI) + go c.handleClicks() + + if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { + startAppUpdateChecker(c.mAbout) } + c.startProfileSelectionHandlers() +} - markActiveProfile := func(active int, count int) { - for i := 0; i < count && i < maxProfiles; i++ { - if i == active { - profileItems[i].Check() - profileItems[i].Disable() - } else { - profileItems[i].Uncheck() - profileItems[i].Enable() - } - } +func (c *trayController) onBusyChanged(busy bool) { + if busy { + c.mCancel.Show() + c.mSchedule.Hide() + c.mInit.Hide() + c.mBackup.Disable() + c.mPrune.Disable() + c.mCheck.Disable() + c.mUnlock.Disable() + c.mPreHook.Disable() + c.mPostHook.Disable() } +} - mDownload := systray.AddMenuItem("Install Backend", "Install selected backup backend") - mDownload.Hide() - mUpdate := systray.AddMenuItem("", "Update backend binary") - mUpdate.Hide() - mSchedule := systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") - mSchedule.Hide() - mCancel := systray.AddMenuItem("Cancel Operation", "Cancel running operation") - mCancel.Hide() - mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") - mInit.Hide() - mRepo := systray.AddMenuItem("Operations", "Repository operations") - mRepo.Disable() - mBackup := mRepo.AddSubMenuItem("Backup", "Run a backup") - mBackup.Disable() - mPrune := mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") - mPrune.Disable() - mCheck := mRepo.AddSubMenuItem("Check", "Verify repository integrity") - mCheck.Disable() - mUnlock := mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") - mUnlock.Disable() - mMount := mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") - mConsole := mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") - mPreHook := mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") - mPreHook.Disable() - mPostHook := mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") - mPostHook.Disable() - systray.AddSeparator() - mConfigure := systray.AddMenuItem("Configure", "Restray settings") - mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") - mFDA.Hide() - mWebEditor := mConfigure.AddSubMenuItem("Open Web Editor", "Open the config/env web editor in your browser") - mSettings := mConfigure.AddSubMenuItem("Edit Config File", "Open config file in editor") - mEnv := mConfigure.AddSubMenuItem("Edit Env File", "Open env file in editor") - mFixPerms := mConfigure.AddSubMenuItem("Fix Permissions", "Config directory, config file, or env file permissions too open") - mFixPerms.Hide() - mLog := mConfigure.AddSubMenuItem("View Log", "Open log file in editor") - mFolder := mConfigure.AddSubMenuItem("Open Folder", "Open folder in file manager") - mAbout := mConfigure.AddSubMenuItem("Restray v"+version, "Open repository in browser") - systray.AddSeparator() - mQuit := systray.AddMenuItem("Quit", "Quit Restray") - - sched := cron.New() - var applyConfig func() - var applyMu sync.Mutex - pendingFullApply := false - - state.onBusyChanged = func(busy bool) { - if busy { - mCancel.Show() - mSchedule.Hide() - mInit.Hide() - mBackup.Disable() - mPrune.Disable() - mCheck.Disable() - mUnlock.Disable() - mPreHook.Disable() - mPostHook.Disable() - } +func (c *trayController) stopScheduler() { + c.schedulerMu.RLock() + defer c.schedulerMu.RUnlock() + if c.scheduler != nil { + c.scheduler.Stop() } +} - createOrEdit := func(m *systray.MenuItem, label, path string) { - if _, err := os.Stat(path); err != nil { - m.SetTitle("Create " + label) - } else { - m.SetTitle("Edit " + label) - } - m.Enable() +func (c *trayController) formatNext(entryID cron.EntryID) string { + c.schedulerMu.RLock() + defer c.schedulerMu.RUnlock() + if c.scheduler == nil || entryID == 0 { + return "" } + entry := c.scheduler.Entry(entryID) + if entry.ID == 0 { + return "" + } + return humanize.Time(entry.Schedule.Next(time.Now())) +} - hideActions := func() { - mSchedule.Hide() - mInit.Hide() - mDownload.Hide() - mUpdate.Hide() - mRepo.Disable() +func profileKeys(cfg Config) []stateKey { + keys := make([]stateKey, 0, len(cfg.Profiles)) + for _, prof := range cfg.Profiles { + keys = append(keys, prof.profileKey()) } + return keys +} - selectedProfile := func() Profile { - cfg := loadConfig() - idx := getActiveProfile() - if idx < len(cfg.Profiles) { - return cfg.Profiles[idx] - } - if len(cfg.Profiles) > 0 { - return cfg.Profiles[0] - } - return Profile{} +func (c *trayController) activeProfile(cfg Config) (int, Profile, bool) { + if idx, prof, err := resolveProfileIndex(cfg, activeProfileKey()); err == nil { + return idx, prof, true } + if len(cfg.Profiles) == 0 || cfg.loadErr != nil { + return 0, Profile{}, false + } + return 0, cfg.Profiles[0], true +} - doDownload := func() { - setIconAnimated("download") - if err := downloadBackend(selectedProfile(), mGlobalStatus); err != nil { - setIconAnimated("fail") - mGlobalStatus.SetTitle("Download failed: " + err.Error()) - mDownload.Show() - return - } - applyConfig() +func (c *trayController) selectedProfile() Profile { + _, prof, ok := c.activeProfile(loadConfig()) + if ok { + return prof } + return Profile{} +} - statusItem := func(idx int) prefixedMenuItem { - cfg := loadConfig() - prefix := "" - if idx < len(cfg.Profiles) { - prefix = profilePrefix(cfg, cfg.Profiles[idx]) - } - return prefixedMenuItem{mStatusItems[idx], prefix} +func (c *trayController) setProfileTitle(cfg Config, idx int) { + if idx < len(cfg.Profiles) { + c.mProfile.SetTitle(cfg.Profiles[idx].displayName()) } + if len(cfg.Profiles) > 1 { + c.mProfile.Enable() + } else { + c.mProfile.Disable() + } +} - profileStatusText := func(cfg Config, i int) string { - profileMu.Lock() - if i >= len(profileStates) { - profileMu.Unlock() - return "" +func (c *trayController) markActiveProfile(active, count int) { + for i := 0; i < count && i < maxProfiles; i++ { + if i == active { + c.profileItems[i].Check() + c.profileItems[i].Disable() + } else { + c.profileItems[i].Uncheck() + c.profileItems[i].Enable() } - ps := profileStates[i] - eids := profileEntryIDs[i] - profileMu.Unlock() - prof := cfg.Profiles[i] - prefix := profilePrefix(cfg, prof) + } +} - switch { - case ps.errMsg != "": - return prefix + ps.errMsg - case ps.repoErr != "": - return prefix + ps.repoErr - default: - paused := !prof.Schedule.OnBattery && onBatteryPower() - if prof.Schedule.Cron == "" { - if cfg.GUI.ScheduleDisplay == "last" { - last := formatLastBackup(getLastBackup(prof.profileKey())) - return prefix + "Unscheduled, last " + last - } - return prefix + "Unscheduled" +func createOrEdit(m *systray.MenuItem, label, path string) { + if _, err := os.Stat(path); err != nil { + m.SetTitle("Create " + label) + } else { + m.SetTitle("Edit " + label) + } + m.Enable() +} + +func (c *trayController) hideActions() { + c.mSchedule.Hide() + c.mInit.Hide() + c.mDownload.Hide() + c.mUpdate.Hide() + c.mRepo.Disable() +} + +func (c *trayController) doDownload() { + setIconAnimated("download") + if err := downloadBackend(c.selectedProfile(), c.mGlobalStatus); err != nil { + setIconAnimated("fail") + c.mGlobalStatus.SetTitle("Download failed: " + err.Error()) + c.mDownload.Show() + return + } + c.applyConfig() +} + +func (c *trayController) profileStatusText(cfg Config, idx int) string { + if idx < 0 || idx >= len(cfg.Profiles) { + return "" + } + prof := cfg.Profiles[idx] + ps, ok := getProfileState(prof.profileKey()) + if !ok { + return "" + } + prefix := profilePrefix(cfg, prof) + + switch { + case ps.errMsg != "": + return prefix + ps.errMsg + case ps.scheduleErr != "": + return prefix + ps.scheduleErr + case ps.repoErr != "": + return prefix + ps.repoErr + default: + paused := schedulePausedForBattery(prof) + if prof.Schedule.Cron == "" { + if cfg.GUI.ScheduleDisplay == "last" { + return prefix + "Unscheduled, last " + formatLastBackup(getLastBackup(prof.profileKey())) } - if paused { - return prefix + "Paused (battery)" + return prefix + "Unscheduled" + } + if paused { + return prefix + "Paused (battery)" + } + next := c.formatNext(ps.entryID) + switch cfg.GUI.ScheduleDisplay { + case "none", "hidden": + if next != "" { + return prefix + next } - next := formatNextFor(sched, eids) - display := cfg.GUI.ScheduleDisplay - switch display { - case "none", "hidden": - if next != "" { - return prefix + next - } - return prefix + "Scheduled" - case "cron": - if next != "" { - return prefix + next + " - " + prof.Schedule.Cron - } - return prefix + prof.Schedule.Cron - case "last": - last := formatLastBackup(getLastBackup(prof.profileKey())) - if next != "" { - return prefix + next + ", last " + last - } - return prefix + "last " + last - default: - schedule := describeCron(prof.Schedule.Cron) - if next != "" { - return prefix + next + " - " + schedule - } - return prefix + schedule + return prefix + "Scheduled" + case "cron": + if next != "" { + return prefix + next + " - " + prof.Schedule.Cron + } + return prefix + prof.Schedule.Cron + case "last": + last := formatLastBackup(getLastBackup(prof.profileKey())) + if next != "" { + return prefix + next + ", last " + last + } + return prefix + "last " + last + default: + schedule := describeCron(prof.Schedule.Cron) + if next != "" { + return prefix + next + " - " + schedule } + return prefix + schedule } } +} - updateAllStatusItems := func(cfg Config) { - mGlobalStatus.Hide() - for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { - if !isProfileBusy(cfg.Profiles[i].profileKey()) { - if failMsg := getProfileFailStatus(cfg.Profiles[i].profileKey()); failMsg != "" { - statusItem(i).SetTitle(failMsg) - } else { - mStatusItems[i].SetTitle(profileStatusText(cfg, i)) - } +func (c *trayController) updateAllStatusItems(cfg Config) { + c.mGlobalStatus.Hide() + for i := 0; i < len(cfg.Profiles) && i < maxProfiles; i++ { + prof := cfg.Profiles[i] + if !isProfileBusy(prof.profileKey()) { + if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" { + prefixedMenuItem{c.mStatusItems[i], profilePrefix(cfg, prof)}.SetTitle(failMsg) + } else { + c.mStatusItems[i].SetTitle(c.profileStatusText(cfg, i)) } - mStatusItems[i].Show() - } - for i := len(cfg.Profiles); i < maxProfiles; i++ { - mStatusItems[i].Hide() } + c.mStatusItems[i].Show() } + for i := len(cfg.Profiles); i < maxProfiles; i++ { + c.mStatusItems[i].Hide() + } +} - applyProfileUI := func(cfg Config) { - profileMu.Lock() - idx := activeProfile - if idx >= len(profileStates) || idx >= len(cfg.Profiles) { - profileMu.Unlock() - return - } - ps := profileStates[idx] - profileMu.Unlock() - prof := cfg.Profiles[idx] +func (c *trayController) applyProfileUI(cfg Config) { + _, prof, ok := c.activeProfile(cfg) + if !ok { + return + } + ps, ok := getProfileState(prof.profileKey()) + if !ok { + return + } - createOrEdit(mEnv, "Env File", prof.EnvFile) - if envFilesInsecure(cfg.Profiles) { - mFixPerms.Show() + createOrEdit(c.mEnv, "Env File", prof.EnvFile) + if envFilesInsecure(cfg.Profiles) { + c.mFixPerms.Show() + } else { + c.mFixPerms.Hide() + } + + c.mSchedule.Hide() + c.mInit.Hide() + c.mRepo.Disable() + c.mPreHook.Disable() + c.mPostHook.Disable() + + c.mMount.Show() + if isProfileMounted(prof.profileKey()) { + c.mMount.SetTitle("Unmount") + c.mMount.Enable() + } else if mountSupported(prof) { + c.mMount.SetTitle("Mount") + c.mMount.Enable() + } else { + c.mMount.SetTitle("Mount") + c.mMount.Disable() + } + + if isProfileBusy(prof.profileKey()) { + c.mCancel.Show() + c.mRepo.Enable() + c.mBackup.Disable() + c.mPrune.Disable() + c.mCheck.Disable() + c.mUnlock.Disable() + return + } + + c.mCancel.Hide() + switch { + case ps.errMsg != "": + case ps.repoErr != "": + if ps.needsInit { + c.mInit.Show() + } + default: + c.mRepo.Enable() + if len(prof.Backup.Paths) > 0 { + c.mBackup.Enable() } else { - mFixPerms.Hide() + c.mBackup.Disable() } - - mSchedule.Hide() - mInit.Hide() - mRepo.Disable() - mPreHook.Disable() - mPostHook.Disable() - - mMount.Show() - if isProfileMounted(prof.profileKey()) { - mMount.SetTitle("Unmount") - mMount.Enable() - } else if mountSupported(prof) { - mMount.SetTitle("Mount") - mMount.Enable() + c.mPrune.Enable() + c.mCheck.Enable() + if prof.backend().supportsUnlock() { + c.mUnlock.Enable() } else { - mMount.SetTitle("Mount") - mMount.Disable() + c.mUnlock.Disable() } - - if isProfileBusy(prof.profileKey()) { - mCancel.Show() - mRepo.Enable() - mBackup.Disable() - mPrune.Disable() - mCheck.Disable() - mUnlock.Disable() + c.mSchedule.Show() + if prof.scheduleDefinitionError() == "" { + c.mSchedule.Enable() } else { - mCancel.Hide() - switch { - case ps.errMsg != "": - case ps.repoErr != "": - if ps.needsInit { - mInit.Show() - } - default: - mRepo.Enable() - mBackup.Enable() - mPrune.Enable() - mCheck.Enable() - if prof.backend().supportsUnlock() { - mUnlock.Enable() - } else { - mUnlock.Disable() - } - mSchedule.Show() - mSchedule.Enable() - if prof.PreHook != "" { - mPreHook.Enable() - } - if prof.PostHook != "" { - mPostHook.Enable() - } - } + c.mSchedule.Disable() } - } - - probeProfileState := func(cfg Config, i int, prof Profile) profileState { - var ps profileState - if path, _ := findBackend(prof); path == "" { - ps.errMsg = prof.backendDisplayName() + " not found" - return ps + if prof.PreHook != "" { + c.mPreHook.Enable() } - ps.errMsg = prof.profileError() - if ps.errMsg == "" { - wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() - var rs repoResult - if wantsLast { - var last time.Time - rs, last = repoStatusAndLastBackup(prof) - if !last.IsZero() { - setLastBackup(prof.profileKey(), last) - } - } else { - rs = repoStatus(prof) - } - ps.repoErr = rs.errMsg - ps.needsInit = rs.needsInit + if prof.PostHook != "" { + c.mPostHook.Enable() } - return ps } +} - refreshProfile := func(idx int) { - applyMu.Lock() - cfg := loadConfig() - if idx < 0 || idx >= len(cfg.Profiles) || idx >= len(profileStates) || idx >= maxProfiles { - applyMu.Unlock() - applyConfig() - return - } - defer applyMu.Unlock() - prof := cfg.Profiles[idx] - mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") - ps := probeProfileState(cfg, idx, prof) - profileMu.Lock() - if idx < len(profileStates) { - profileStates[idx] = ps - } - profileMu.Unlock() - if !isProfileBusy(prof.profileKey()) { - if failMsg := getProfileFailStatus(prof.profileKey()); failMsg != "" { - statusItem(idx).SetTitle(failMsg) - } else { - mStatusItems[idx].SetTitle(profileStatusText(cfg, idx)) +func (c *trayController) probeProfileState(cfg Config, prof Profile) profileState { + var ps profileState + if prof.Schedule.Cron != "" { + ps.scheduleErr = prof.scheduleDefinitionError() + if ps.scheduleErr == "" { + if _, err := cron.ParseStandard(prof.Schedule.Cron); err != nil { + ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron } } - if !isAnyBusy() { - if anyError() { - setIconAnimated("fail") - } else { - setIconAnimated("idle") + } + if path, _ := findBackend(prof); path == "" { + ps.errMsg = prof.backendDisplayName() + " not found" + return ps + } + ps.errMsg = prof.repositoryError() + if ps.errMsg == "" { + wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(prof.profileKey()).IsZero() + var rs repoResult + if wantsLast { + var last time.Time + rs, last = repoStatusAndLastBackup(prof) + if !last.IsZero() { + setLastBackup(prof.profileKey(), last) } + } else { + rs = repoStatus(prof) } - applyProfileUI(cfg) + ps.repoErr = rs.errMsg + ps.needsInit = rs.needsInit } + return ps +} - finishProfile := func(idx int) { - applyMu.Lock() - pending := pendingFullApply - pendingFullApply = false - applyMu.Unlock() - if pending { - applyConfig() - return +func (c *trayController) refreshProfile(key stateKey) { + c.applyMu.Lock() + cfg := loadConfig() + idx, prof, err := resolveProfileIndex(cfg, key) + if err != nil || idx >= maxProfiles { + c.applyMu.Unlock() + c.applyConfig() + return + } + defer c.applyMu.Unlock() + + c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") + setProfileProbeState(key, c.probeProfileState(cfg, prof)) + if !isProfileBusy(key) { + if failMsg := getProfileFailStatus(key); failMsg != "" { + prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}.SetTitle(failMsg) + } else { + c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) } - refreshProfile(idx) } - - applyConfig = func() { - applyMu.Lock() - defer applyMu.Unlock() - if isAnyBusy() { - pendingFullApply = true - cfg := loadConfig() - if applyIconMode(cfg.GUI.Icon) { - refreshIcon() - } - profileMu.Lock() - if activeProfile >= len(cfg.Profiles) { - activeProfile = 0 - } - activeIdx := activeProfile - profileMu.Unlock() - markActiveProfile(activeIdx, len(cfg.Profiles)) - setProfileTitle(cfg, activeIdx) - updateAllStatusItems(cfg) - applyProfileUI(cfg) - return - } - pendingFullApply = false - cfg := loadConfig() - applyIconMode(cfg.GUI.Icon) - if cfg.loadErr != nil { + if !isAnyBusy() { + if anyError() { setIconAnimated("fail") - mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr)) - mGlobalStatus.Show() - hideActions() - mProfile.Disable() - for i := range mStatusItems { - mStatusItems[i].Hide() - profileItems[i].Hide() - } - return + } else { + setIconAnimated("idle") } - state.mu.Lock() - state.notifications = strings.ToLower(cfg.GUI.Notifications) - state.mu.Unlock() - sched.Stop() - sched = cron.New() + } + c.applyProfileUI(cfg) +} - createOrEdit(mSettings, "Config File", configPath()) +func (c *trayController) finishProfile(key stateKey) { + c.applyMu.Lock() + pending := c.pendingFullApply + c.pendingFullApply = false + c.applyMu.Unlock() + if pending { + c.applyConfig() + return + } + c.refreshProfile(key) +} - mDownload.Hide() - mUpdate.Hide() +func (c *trayController) applyConfig() { + c.applyMu.Lock() + defer c.applyMu.Unlock() - prof := selectedProfile() - backendPath, managed := findBackend(prof) - backendName := prof.backendDisplayName() + if isAnyBusy() { + c.pendingFullApply = true + cfg := loadConfig() + if applyIconMode(cfg.GUI.Icon) { + refreshIcon() + } + activeKey := resetProfileState(profileKeys(cfg)) + activeIdx, _, err := resolveProfileIndex(cfg, activeKey) + if err != nil { + activeIdx = 0 + } + c.markActiveProfile(activeIdx, len(cfg.Profiles)) + c.setProfileTitle(cfg, activeIdx) + c.updateAllStatusItems(cfg) + c.applyProfileUI(cfg) + return + } + + c.pendingFullApply = false + cfg := loadConfig() + applyIconMode(cfg.GUI.Icon) + if cfg.loadErr != nil { + setIconAnimated("fail") + c.mGlobalStatus.SetTitle(configLoadErrorMessage(cfg.loadErr)) + c.mGlobalStatus.Show() + c.hideActions() + c.mProfile.Disable() + for i := range c.mStatusItems { + c.mStatusItems[i].Hide() + c.profileItems[i].Hide() + } + return + } + state.mu.Lock() + state.notifications = strings.ToLower(cfg.GUI.Notifications) + state.mu.Unlock() - if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { - mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") - mGlobalStatus.Show() - go doDownload() - return - } - if backendPath == "" { - setIconAnimated("fail") - mGlobalStatus.SetTitle(backendName + " not found") - mGlobalStatus.Show() - hideActions() - mDownload.SetTitle("Install " + backendName) - mDownload.Show() - mProfile.Disable() - return - } + c.schedulerMu.Lock() + oldScheduler := c.scheduler + c.scheduler = nil + c.schedulerMu.Unlock() + if oldScheduler != nil { + oldScheduler.Stop() + } - if needsFullDiskAccess() { - mFDA.Show() - } else { - mFDA.Hide() - } + createOrEdit(c.mSettings, "Config File", configPath()) + c.mDownload.Hide() + c.mUpdate.Hide() - profileMu.Lock() - profileStates = make([]profileState, len(cfg.Profiles)) - profileEntryIDs = make(map[int][]cron.EntryID) - if activeProfile >= len(cfg.Profiles) { - activeProfile = 0 + keys := profileKeys(cfg) + activeKey := resetProfileState(keys) + for _, key := range keys { + setProfileCronEntry(key, 0) + } + activeIdx, prof, err := resolveProfileIndex(cfg, activeKey) + if err != nil { + activeIdx = 0 + prof = Profile{} + } + backendPath, managed := findBackend(prof) + backendName := prof.backendDisplayName() + + if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { + c.mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") + c.mGlobalStatus.Show() + go c.doDownload() + return + } + if backendPath == "" { + setIconAnimated("fail") + c.mGlobalStatus.SetTitle(backendName + " not found") + c.mGlobalStatus.Show() + c.hideActions() + c.mDownload.SetTitle("Install " + backendName) + c.mDownload.Show() + c.mProfile.Disable() + return + } + + if needsFullDiskAccess() { + c.mFDA.Show() + } else { + c.mFDA.Hide() + } + + nextScheduler := cron.New() + nextScheduler.Start() + c.schedulerMu.Lock() + c.scheduler = nextScheduler + c.schedulerMu.Unlock() + + c.markActiveProfile(activeIdx, len(cfg.Profiles)) + c.setProfileTitle(cfg, activeIdx) + setIconAnimated("busy") + + var wg sync.WaitGroup + for i, prof := range cfg.Profiles { + label := prof.displayName() + if i < maxProfiles { + connecting := "Connecting..." + if len(cfg.Profiles) > 1 { + connecting = label + " - " + connecting + } + c.mStatusItems[i].SetTitle(connecting) + c.mStatusItems[i].Show() + c.profileItems[i].SetTitle(label) + c.profileItems[i].Show() } - activeIdx := activeProfile - profileMu.Unlock() - - markActiveProfile(activeIdx, len(cfg.Profiles)) - setProfileTitle(cfg, activeIdx) - - sched.Start() - setIconAnimated("busy") - - var wg sync.WaitGroup - for i, prof := range cfg.Profiles { - label := prof.displayName() - if i < maxProfiles { - connecting := "Connecting..." - if len(cfg.Profiles) > 1 { - connecting = label + " - " + connecting - } - mStatusItems[i].SetTitle(connecting) - mStatusItems[i].Show() - profileItems[i].SetTitle(label) - profileItems[i].Show() - } - wg.Add(1) - go func(i int, prof Profile) { - defer wg.Done() - ps := probeProfileState(cfg, i, prof) - - if prof.Schedule.Cron != "" && i < maxProfiles { - if errMsg := prof.scheduleConfigError(); errMsg != "" { - log.Printf("[%s] skipping schedule: %s", prof.displayName(), errMsg) - } else { - key := prof.profileKey() - name := prof.displayName() - eid, err := sched.AddFunc(prof.Schedule.Cron, func() { - current := loadConfig() - idx, prof, err := resolveProfileIndex(current, key) - if err != nil { - log.Printf("[%s] skipping scheduled run: %v", name, err) - return - } - if skipForBattery(prof) { - log.Printf("[%s] skipping scheduled run: on battery power", prof.displayName()) - return - } - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("[%s] scheduled run panicked: %v", prof.displayName(), r) - } - }() - runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) - }() - }) - if err != nil { - log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) - ps.errMsg = "Invalid cron: " + prof.Schedule.Cron - } else { - profileMu.Lock() - profileEntryIDs[i] = append(profileEntryIDs[i], eid) - profileMu.Unlock() - } - } - } + wg.Add(1) + go func() { + defer wg.Done() + c.initializeProfile(cfg, nextScheduler, i, prof, activeKey) + }() + } + wg.Wait() - profileMu.Lock() - profileStates[i] = ps - profileMu.Unlock() - if i < maxProfiles { - mStatusItems[i].SetTitle(profileStatusText(cfg, i)) - } - if i == activeIdx { - applyProfileUI(cfg) - } - }(i, prof) - } - wg.Wait() + for i := len(cfg.Profiles); i < maxProfiles; i++ { + c.profileItems[i].Hide() + c.profileItems[i].Uncheck() + } + if anyError() { + setIconAnimated("fail") + } else { + setIconAnimated("idle") + } + c.updateAllStatusItems(cfg) + c.applyProfileUI(cfg) - for i := len(cfg.Profiles); i < maxProfiles; i++ { - profileItems[i].Hide() - profileItems[i].Uncheck() - } + if managed { + go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), c.mUpdate, c.mGlobalStatus, c.applyConfig) + } +} - if anyError() { - setIconAnimated("fail") +func (c *trayController) initializeProfile(cfg Config, sched *cron.Cron, idx int, prof Profile, activeKey stateKey) { + key := prof.profileKey() + ps := c.probeProfileState(cfg, prof) + if prof.Schedule.Cron != "" && idx < maxProfiles { + if ps.scheduleErr != "" { + log.Printf("[%s] skipping schedule: %s", prof.displayName(), ps.scheduleErr) } else { - setIconAnimated("idle") - } - - updateAllStatusItems(cfg) - applyProfileUI(cfg) - - if managed { - go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), mUpdate, mGlobalStatus, applyConfig) + eid, err := sched.AddFunc(prof.Schedule.Cron, func() { + runScheduleCallback(key, func() { c.finishProfile(key) }, func(currentCfg Config, currentIdx int, current Profile) { + if currentIdx >= maxProfiles { + return + } + item := prefixedMenuItem{c.mStatusItems[currentIdx], profilePrefix(currentCfg, current)} + runScheduledAcquired(key, item, current) + }) + }) + if err != nil { + log.Printf("[%s] invalid cron expression %q: %v", prof.displayName(), prof.Schedule.Cron, err) + ps.scheduleErr = "Invalid cron: " + prof.Schedule.Cron + } else { + setProfileCronEntry(key, eid) + } } } - - go applyConfig() - if firstLaunch && needsFullDiskAccess() { - go promptFullDiskAccess() + setProfileProbeState(key, ps) + if idx < maxProfiles { + c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) } - watchConfig(applyConfig) + if key == activeKey { + c.applyProfileUI(cfg) + } +} - startTrayBackendMonitor(selectedProfile, applyConfig, mUpdate, mGlobalStatus) - startTrayStatusMonitor(applyConfig, updateAllStatusItems, applyProfileUI) +func (c *trayController) selectedOperation() (Profile, prefixedMenuItem, bool) { + cfg := loadConfig() + idx, prof, ok := c.activeProfile(cfg) + if !ok || idx >= maxProfiles { + return Profile{}, prefixedMenuItem{}, false + } + return prof, prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}, true +} - repoOp := func(status string, args ...string) { - idx := getActiveProfile() - prof := selectedProfile() - if idx >= maxProfiles { +func (c *trayController) repoOp(prof Profile, ms prefixedMenuItem, status string, args ...string) { + key := prof.profileKey() + go func() { + if !acquireProfile(key) { return } - ms := statusItem(idx) - go func() { - if !acquireProfile(prof.profileKey()) { - return + setProfileFailed(key, "") + defer c.finishProfile(key) + defer releaseProfile(key) + ms.SetTitle(status) + if msg, err := runBackend(key, prof, ms, args...); err != nil { + setProfileFailed(key, msg) + notifyError(args[0], msg) + } else { + notifySuccess(args[0]) + } + }() +} + +func (c *trayController) runManualOperation(run func(stateKey, prefixedMenuItem, Profile, func())) { + prof, item, ok := c.selectedOperation() + if !ok { + return + } + key := prof.profileKey() + go run(key, item, prof, func() { c.finishProfile(key) }) +} + +func (c *trayController) handleClicks() { + for { + select { + case <-c.mSchedule.ClickedCh: + c.runManualOperation(runScheduled) + case <-c.mBackup.ClickedCh: + c.runManualOperation(runBackup) + case <-c.mCancel.ClickedCh: + cancelProfile(c.selectedProfile().profileKey()) + case <-c.mPrune.ClickedCh: + if prof, item, ok := c.selectedOperation(); ok && len(prof.Prune.Args) > 0 { + c.repoOp(prof, item, "Pruning repository...", forgetArgs(prof)...) + } + case <-c.mCheck.ClickedCh: + if prof, item, ok := c.selectedOperation(); ok { + c.repoOp(prof, item, "Checking repository...", checkArgs(prof)...) + } + case <-c.mUnlock.ClickedCh: + if prof, item, ok := c.selectedOperation(); ok && prof.backend().supportsUnlock() { + c.repoOp(prof, item, "Unlocking repository...", "unlock") } - setProfileFailed(prof.profileKey(), "") - defer finishProfile(idx) - defer releaseProfile(prof.profileKey()) - ms.SetTitle(status) - if msg, err := runBackend(prof.profileKey(), prof, ms, args...); err != nil { - setProfileFailed(prof.profileKey(), msg) - notifyError(args[0], msg) + case <-c.mInit.ClickedCh: + if prof, item, ok := c.selectedOperation(); ok { + c.repoOp(prof, item, "Initializing repository...", "init") + } + case <-c.mMount.ClickedCh: + prof := c.selectedProfile() + key := prof.profileKey() + if isProfileMounted(key) { + stopProfileMount(key) } else { - notifySuccess(args[0]) + go startMount(key, prof, c.mMount, func() { c.finishProfile(key) }) } - }() - } - - go func() { - for { - select { - case <-mSchedule.ClickedCh: - idx := getActiveProfile() - prof := selectedProfile() - if idx < maxProfiles { - go runScheduled(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) - } - case <-mBackup.ClickedCh: - idx := getActiveProfile() - prof := selectedProfile() - if idx < maxProfiles { - go runBackup(prof.profileKey(), statusItem(idx), prof, func() { finishProfile(idx) }) - } - case <-mCancel.ClickedCh: - cancelProfile(selectedProfile().profileKey()) - case <-mPrune.ClickedCh: - prof := selectedProfile() - if len(prof.Prune.Args) > 0 { - repoOp("Pruning repository...", forgetArgs(prof)...) - } - case <-mCheck.ClickedCh: - repoOp("Checking repository...", checkArgs(selectedProfile())...) - case <-mUnlock.ClickedCh: - if selectedProfile().backend().supportsUnlock() { - repoOp("Unlocking repository...", "unlock") - } - case <-mInit.ClickedCh: - repoOp("Initializing repository...", "init") - case <-mMount.ClickedCh: - idx := getActiveProfile() - prof := selectedProfile() - if isProfileMounted(prof.profileKey()) { - stopProfileMount(prof.profileKey()) - } else { - go startMount(prof.profileKey(), prof, mMount, func() { finishProfile(idx) }) - } - case <-mConsole.ClickedCh: - cfg := loadConfig() - openConsole(selectedProfile(), len(cfg.Profiles), cfg.GUI.Terminal, "") - case <-mPreHook.ClickedCh: - prof := selectedProfile() - if prof.PreHook != "" { - go func() { - if err := runHook(prof.PreHook, prof); err != nil { - notifyError("Pre-hook", "Pre-hook failed") - } else { - notifySuccess("Pre-hook") - } - }() - } - case <-mPostHook.ClickedCh: - prof := selectedProfile() - if prof.PostHook != "" { - go func() { - if err := runHook(prof.PostHook, prof); err != nil { - notifyError("Post-hook", "Post-hook failed") - } else { - notifySuccess("Post-hook") - } - }() - } - case <-mDownload.ClickedCh: - if !selfManagesBackend { - if selectedProfile().backend() == BackendRustic { - openFile("https://rustic.cli.rs/docs/installation.html") - } else { - openFile("https://restic.readthedocs.io/en/stable/020_installation.html") - } - } else if !isAnyBusy() { - hideActions() - go doDownload() - } - case <-mUpdate.ClickedCh: - if selfManagesBackend && !isAnyBusy() { - hideActions() - go doDownload() - } - case <-mSettings.ClickedCh: - if err := ensureConfigFile(); err != nil { - log.Printf("config: %v", err) - continue - } - openInEditor(configPath()) - case <-mWebEditor.ClickedCh: - if err := ensureConfigFile(); err != nil { - log.Printf("config: %v", err) - continue - } - url, err := ensureWebEditor() - if err != nil { - log.Printf("webeditor: %v", err) - continue - } - openFile(url) - case <-mEnv.ClickedCh: - prof := selectedProfile() - if err := ensureEnvFile(prof.EnvFile); err != nil { - log.Printf("env: %v", err) - continue - } - openInEditor(prof.EnvFile) - case <-mFDA.ClickedCh: - openFullDiskAccessSettings() - case <-mFixPerms.ClickedCh: - cfg := loadConfig() - fixEnvFilePermissions(cfg.Profiles) - if envFilesInsecure(cfg.Profiles) { - mFixPerms.Show() + case <-c.mConsole.ClickedCh: + cfg := loadConfig() + if _, prof, ok := c.activeProfile(cfg); ok { + openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, "") + } + case <-c.mPreHook.ClickedCh: + prof := c.selectedProfile() + c.runManualHook(prof, prof.PreHook, "Pre-hook") + case <-c.mPostHook.ClickedCh: + prof := c.selectedProfile() + c.runManualHook(prof, prof.PostHook, "Post-hook") + case <-c.mDownload.ClickedCh: + if !selfManagesBackend { + if c.selectedProfile().backend() == BackendRustic { + openFile("https://rustic.cli.rs/docs/installation.html") } else { - mFixPerms.Hide() - } - if !isAnyBusy() { - applyConfig() + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") } - case <-mLog.ClickedCh: - cfg := loadConfig() - openConsole(selectedProfile(), len(cfg.Profiles), cfg.GUI.Terminal, logPath()) - case <-mFolder.ClickedCh: - openFile(configDir()) - case <-mAbout.ClickedCh: - if v := latestKnownVersion.Load(); v != nil { - if applyUpdate(*v) { - sched.Stop() - stopAllMounts() - systray.Quit() - } - } else { - openFile("https://tangled.org/devins.page/restray") + } else if !isAnyBusy() { + c.hideActions() + go c.doDownload() + } + case <-c.mUpdate.ClickedCh: + if selfManagesBackend && !isAnyBusy() { + c.hideActions() + go c.doDownload() + } + case <-c.mSettings.ClickedCh: + if err := ensureConfigFile(); err != nil { + log.Printf("config: %v", err) + continue + } + openInEditor(configPath()) + case <-c.mWebEditor.ClickedCh: + if err := ensureConfigFile(); err != nil { + log.Printf("config: %v", err) + continue + } + url, err := ensureWebEditor() + if err != nil { + log.Printf("webeditor: %v", err) + continue + } + openFile(url) + case <-c.mEnv.ClickedCh: + prof := c.selectedProfile() + if err := ensureEnvFile(prof.EnvFile); err != nil { + log.Printf("env: %v", err) + continue + } + openInEditor(prof.EnvFile) + case <-c.mFDA.ClickedCh: + openFullDiskAccessSettings() + case <-c.mFixPerms.ClickedCh: + cfg := loadConfig() + fixEnvFilePermissions(cfg.Profiles) + if envFilesInsecure(cfg.Profiles) { + c.mFixPerms.Show() + } else { + c.mFixPerms.Hide() + } + if !isAnyBusy() { + c.applyConfig() + } + case <-c.mLog.ClickedCh: + cfg := loadConfig() + if _, prof, ok := c.activeProfile(cfg); ok { + openConsole(prof, len(cfg.Profiles), cfg.GUI.Terminal, logPath()) + } + case <-c.mFolder.ClickedCh: + openFile(configDir()) + case <-c.mAbout.ClickedCh: + if v := latestKnownVersion.Load(); v != nil { + if applyUpdate(*v) { + c.stopScheduler() + stopAllMounts() + systray.Quit() } - case <-mQuit.ClickedCh: - sched.Stop() - stopAllMounts() - systray.Quit() + } else { + openFile("https://tangled.org/devins.page/restray") } + case <-c.mQuit.ClickedCh: + c.stopScheduler() + stopAllMounts() + systray.Quit() } - }() - - if (runtime.GOOS == "windows" || runtime.GOOS == "darwin") && loadConfig().GUI.UpdatesEnabled() { - startAppUpdateChecker(mAbout) } - - startProfileSelectionHandlers(profileItems, func(idx int) { - cfg := loadConfig() - if idx >= len(cfg.Profiles) { - return - } - profileMu.Lock() - activeProfile = idx - profileMu.Unlock() - markActiveProfile(idx, len(cfg.Profiles)) - setProfileTitle(cfg, idx) - applyProfileUI(cfg) - }) } -func formatNextFor(sched *cron.Cron, ids []cron.EntryID) string { - if len(ids) == 0 { - return "" +func (c *trayController) runManualHook(prof Profile, hook, name string) { + if hook == "" { + return } - want := make(map[cron.EntryID]bool, len(ids)) - for _, id := range ids { - want[id] = true - } - now := time.Now() - var earliest time.Time - for _, e := range sched.Entries() { - if !want[e.ID] { - continue - } - next := e.Schedule.Next(now) - if earliest.IsZero() || next.Before(earliest) { - earliest = next + go func() { + if err := runHook(hook, prof); err != nil { + notifyError(name, name+" failed") + } else { + notifySuccess(name) } + }() +} + +func (c *trayController) startProfileSelectionHandlers() { + for i := range c.profileItems { + idx := i + go func() { + for range c.profileItems[idx].ClickedCh { + cfg := loadConfig() + if idx >= len(cfg.Profiles) { + continue + } + setActiveProfileKey(cfg.Profiles[idx].profileKey()) + c.markActiveProfile(idx, len(cfg.Profiles)) + c.setProfileTitle(cfg, idx) + c.applyProfileUI(cfg) + } + }() } - if earliest.IsZero() { - return "" - } - return humanize.Time(earliest) } func formatLastBackup(last time.Time) string { @@ -993,18 +1003,3 @@ func formatLastBackup(last time.Time) string { } return humanize.Time(last) } - -func onBatteryPower() bool { - b, err := battery.Get(0) - if _, ok := err.(battery.ErrFatal); ok { - return false - } - if b == nil { - return false - } - return b.State.Raw == battery.Discharging -} - -func skipForBattery(prof Profile) bool { - return !prof.Schedule.OnBattery && onBatteryPower() -} diff --git a/justfile b/justfile index 0935d03..6fbf121 100644 --- a/justfile +++ b/justfile @@ -238,4 +238,4 @@ bump new: grep -n "$new" {{pkg}}/main.go flake.nix packaging/windows/installer.nsi packaging/darwin/Info.plist clean: - rm -rf {{bin}} {{dist}} result + rm -rf {{bin}} {{dist}} result {{app}} {{app}}.exe