diff --git a/cmd/restray/repository.go b/cmd/restray/repository.go index 208bf6e..6861eba 100644 --- a/cmd/restray/repository.go +++ b/cmd/restray/repository.go @@ -34,8 +34,9 @@ func setEnv(env []string, key, value string) []string { } type repoResult struct { - errMsg string - needsInit bool + errMsg string + needsInit bool + unreachable bool } func probeRepo(prof Profile, args []string) ([]byte, repoResult) { @@ -55,7 +56,7 @@ func probeRepo(prof Profile, args []string) ([]byte, repoResult) { trimmed := strings.TrimSpace(stderr.String()) if ctx.Err() != nil { log.Printf("[%s] repo status timed out after 30s; last output: %s", prof.displayName(), trimmed) - return nil, repoResult{errMsg: "Repository unreachable"} + return nil, repoResult{errMsg: "Repository unreachable", unreachable: true} } log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) lines := strings.Split(trimmed, "\n") @@ -64,12 +65,40 @@ func probeRepo(prof Profile, args []string) ([]byte, repoResult) { if needsInit { msg = "Repository not initialized" } - return nil, repoResult{errMsg: msg, needsInit: needsInit} + return nil, repoResult{ + errMsg: msg, + needsInit: needsInit, + unreachable: repositoryUnreachable(err, trimmed), + } } log.Printf("[%s] repo status ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) return out, repoResult{} } +func repositoryUnreachable(err error, stderr string) bool { + if err == nil { + return false + } + message := strings.ToLower(stderr + "\n" + err.Error()) + for _, marker := range []string{ + "connection refused", + "connection reset", + "connection timed out", + "i/o timeout", + "name or service not known", + "network is unreachable", + "no route to host", + "no such host", + "temporary failure in name resolution", + "tls handshake timeout", + } { + if strings.Contains(message, marker) { + return true + } + } + return false +} + func repoStatus(prof Profile) repoResult { args := []string{"cat", "config"} if prof.backend() == BackendRestic { @@ -136,9 +165,17 @@ func classifyResticError(err error, lastStderr string) (string, int) { if lastStderr != "" { var msg struct { Message string `json:"message"` + Error struct { + Message string `json:"message"` + } `json:"error"` } - if json.Unmarshal([]byte(lastStderr), &msg) == nil && msg.Message != "" { - return msg.Message, code + if json.Unmarshal([]byte(lastStderr), &msg) == nil { + if msg.Message != "" { + return msg.Message, code + } + if msg.Error.Message != "" { + return msg.Error.Message, code + } } return lastStderr, code } @@ -156,7 +193,7 @@ func rusticNeedsInit(output string) bool { func repositoryNeedsInit(prof Profile, code int, output string) bool { switch prof.backend() { case BackendRestic: - return code == 10 + return code == 10 || (code == 1 && strings.Contains(strings.ToLower(output), "repository does not exist")) case BackendRustic: return rusticNeedsInit(output) default: diff --git a/cmd/restray/repository_test.go b/cmd/restray/repository_test.go index db9f882..1d3e768 100644 --- a/cmd/restray/repository_test.go +++ b/cmd/restray/repository_test.go @@ -1,6 +1,33 @@ package main -import "testing" +import ( + "errors" + "testing" +) + +func TestRepositoryUnreachable(t *testing.T) { + tests := []struct { + name string + err error + stderr string + want bool + }{ + {name: "dns failure", err: errors.New("exit status 1"), stderr: "lookup backup.example: no such host", want: true}, + {name: "connection refused", err: errors.New("exit status 1"), stderr: "dial tcp 127.0.0.1:9000: connect: connection refused", want: true}, + {name: "timeout", err: errors.New("exit status 1"), stderr: "dial tcp: i/o timeout", want: true}, + {name: "wrong password", err: errors.New("exit status 1"), stderr: "wrong password", want: false}, + {name: "not initialized", err: errors.New("exit status 1"), stderr: "repository does not exist", want: false}, + {name: "permission denied", err: errors.New("exit status 1"), stderr: "permission denied", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := repositoryUnreachable(tt.err, tt.stderr); got != tt.want { + t.Fatalf("repositoryUnreachable() = %v, want %v", got, tt.want) + } + }) + } +} func TestRepositoryNeedsInit(t *testing.T) { tests := []struct { @@ -11,7 +38,8 @@ func TestRepositoryNeedsInit(t *testing.T) { want bool }{ {"restic exit code", Profile{Backend: "restic"}, 10, "", true}, - {"restic other code", Profile{Backend: "restic"}, 1, "repository does not exist", false}, + {"restic older exit code", Profile{Backend: "restic"}, 1, "repository does not exist", true}, + {"restic wrong password", Profile{Backend: "restic"}, 1, "wrong password", false}, {"rustic missing repository", Profile{Backend: "rustic"}, 1, "Repository is not initialized", true}, {"rustic config missing", Profile{Backend: "rustic"}, 1, "no such file or directory: config", true}, {"rustic auth error", Profile{Backend: "rustic"}, 1, "password failed", false}, @@ -24,3 +52,23 @@ func TestRepositoryNeedsInit(t *testing.T) { }) } } + +func TestClassifyResticError(t *testing.T) { + tests := []struct { + name string + stderr string + want string + }{ + {name: "flat exit error", stderr: `{"message_type":"exit_error","message":"repository unavailable"}`, want: "repository unavailable"}, + {name: "nested command error", stderr: `{"message_type":"error","error":{"message":"source unreadable"}}`, want: "source unreadable"}, + {name: "plain text", stderr: "Fatal: repository unavailable", want: "Fatal: repository unavailable"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, _ := classifyResticError(errors.New("exit status 1"), tt.stderr); got != tt.want { + t.Fatalf("classifyResticError() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cmd/restray/state.go b/cmd/restray/state.go index dda09de..4d1a962 100644 --- a/cmd/restray/state.go +++ b/cmd/restray/state.go @@ -13,6 +13,7 @@ type stateKey string type profileState struct { errMsg string repoErr string + unreachable bool scheduleErr string needsInit bool entryID cron.EntryID diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go index 8d79b28..861639a 100644 --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -47,6 +47,10 @@ func getProfileFailStatus(key stateKey) string { return state.failStatus[key] } +func profileStateHasError(ps profileState) bool { + return ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" +} + func anyError() bool { state.mu.Lock() defer state.mu.Unlock() @@ -54,7 +58,7 @@ func anyError() bool { return true } for _, ps := range state.profileStates { - if ps.errMsg != "" || ps.repoErr != "" || ps.scheduleErr != "" { + if profileStateHasError(ps) { return true } } @@ -69,13 +73,11 @@ func profilePrefix(cfg Config, prof Profile) string { } func unreachableRecovered(cfg Config) bool { - var unreachable []Profile for _, prof := range cfg.Profiles { - if ps, ok := getProfileState(prof.profileKey()); ok && ps.errMsg == "" && ps.repoErr != "" { - unreachable = append(unreachable, prof) + ps, ok := getProfileState(prof.profileKey()) + if !ok || ps.errMsg != "" || !ps.unreachable { + continue } - } - for _, prof := range unreachable { if repoStatus(prof).errMsg == "" { return true } @@ -292,6 +294,7 @@ func (c *trayController) start(firstLaunch bool) { startAppUpdateChecker(c.mAbout) } c.startProfileSelectionHandlers() + c.startProfileStatusHandlers() } func (c *trayController) onBusyChanged(busy bool) { @@ -306,6 +309,7 @@ func (c *trayController) onBusyChanged(busy bool) { c.mPreHook.Disable() c.mPostHook.Disable() } + c.updateProfileStatusItems(loadConfig()) } func (c *trayController) stopScheduler() { @@ -465,20 +469,38 @@ func (c *trayController) profileStatusText(cfg Config, idx int) string { func (c *trayController) updateAllStatusItems(cfg Config) { c.mGlobalStatus.Hide() + c.updateProfileStatusItems(cfg) + for i := len(cfg.Profiles); i < maxProfiles; i++ { + c.mStatusItems[i].Hide() + } +} + +func (c *trayController) updateProfileStatusItems(cfg Config) { 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)) - } + c.updateProfileStatusItem(cfg, i) + } +} + +func (c *trayController) updateProfileStatusItem(cfg Config, idx int) { + prof := cfg.Profiles[idx] + key := prof.profileKey() + busy := isProfileBusy(key) + failMsg := getProfileFailStatus(key) + if !busy { + if failMsg != "" { + prefixedMenuItem{c.mStatusItems[idx], profilePrefix(cfg, prof)}.SetTitle(failMsg) + } else { + c.mStatusItems[idx].SetTitle(c.profileStatusText(cfg, idx)) } - c.mStatusItems[i].Show() } - for i := len(cfg.Profiles); i < maxProfiles; i++ { - c.mStatusItems[i].Hide() + + ps, ok := getProfileState(key) + if !busy && (failMsg != "" || (ok && profileStateHasError(ps))) { + c.mStatusItems[idx].Enable() + } else { + c.mStatusItems[idx].Disable() } + c.mStatusItems[idx].Show() } func (c *trayController) applyProfileUI(cfg Config) { @@ -590,6 +612,7 @@ func (c *trayController) probeProfileState(cfg Config, prof Profile) profileStat rs = repoStatus(prof) } ps.repoErr = rs.errMsg + ps.unreachable = rs.unreachable ps.needsInit = rs.needsInit } return ps @@ -608,13 +631,7 @@ func (c *trayController) refreshProfile(key stateKey) { 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)) - } - } + c.updateProfileStatusItem(cfg, idx) if !isAnyBusy() { if anyError() { setIconAnimated("fail") @@ -625,6 +642,23 @@ func (c *trayController) refreshProfile(key stateKey) { c.applyProfileUI(cfg) } +func (c *trayController) recheckProfile(idx int) { + cfg := loadConfig() + if idx >= len(cfg.Profiles) || idx >= maxProfiles { + return + } + prof := cfg.Profiles[idx] + key := prof.profileKey() + if isProfileBusy(key) { + return + } + + c.mStatusItems[idx].Disable() + setProfileFailed(key, "") + c.mStatusItems[idx].SetTitle(profilePrefix(cfg, prof) + "Connecting...") + c.refreshProfile(key) +} + func (c *trayController) finishProfile(key stateKey) { c.applyMu.Lock() pending := c.pendingFullApply @@ -745,6 +779,7 @@ func (c *trayController) applyConfig() { connecting = label + " - " + connecting } c.mStatusItems[i].SetTitle(connecting) + c.mStatusItems[i].Disable() c.mStatusItems[i].Show() c.profileItems[i].SetTitle(label) c.profileItems[i].Show() @@ -997,6 +1032,17 @@ func (c *trayController) startProfileSelectionHandlers() { } } +func (c *trayController) startProfileStatusHandlers() { + for i := range c.mStatusItems { + idx := i + go func() { + for range c.mStatusItems[idx].ClickedCh { + c.recheckProfile(idx) + } + }() + } +} + func formatLastBackup(last time.Time) string { if last.IsZero() { return "never"