fix: accept zero retention limits, retire Store() for typed accessors

Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now
means "keep everything" end to end. runner.CleanupLogs already treated
<= 0 as disabled; validateConfig, the Settings form, and
loadOrCreateConfig's backfill were the only things making that state
unreachable.

Phase 9 (1.1, rolling up 1.2, 1.3, 7.3): added Service.Config() and
Service.Paths(), copying under mu, and converted every UI site that read
Service state through the raw *storage.Store returned by Store() (now
removed). jobs_view's pause control is now driven by refreshView reading
svc.Config().Paused on every event instead of only mirroring its own tap
handler, which makes it an actual consumer of SchedulerStateChanged.
mainwindow's event listener is a real type switch, and events.go's doc
comment no longer claims a compiler exhaustiveness check Go doesn't have.
Unexported the redundant SetAutostart/AutostartStatus package functions
in platform/autostart now that only the Manager methods are used outside
the package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:15:19 +03:00
parent 0c8442a8d1
commit ca2a8c8aa7
18 changed files with 223 additions and 115 deletions
+40 -27
View File
@@ -57,13 +57,14 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
selected = -1
}
selectedFolder := allFolders
schedulerPaused := svc.Store().Config.Paused
listView := svc.Store().Config.JobListView
initialConfig := svc.Config()
schedulerPaused := initialConfig.Paused
listView := initialConfig.JobListView
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, initialConfig.OverlapPolicy, initialConfig.DefaultTimeoutSeconds)
if selected >= 0 {
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
dp.update(jobs[selected], runtimeFor(selected), initialConfig.OverlapPolicy, initialConfig.DefaultTimeoutSeconds)
} else {
dp.clear()
}
@@ -76,16 +77,28 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
return
}
selected = index
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
// Overlap policy and the default timeout are global settings that can
// change from the Settings tab while this view is open, so they are
// re-read on every update rather than captured once at construction.
config := svc.Config()
dp.update(jobs[selected], runtimeFor(selected), config.OverlapPolicy, config.DefaultTimeoutSeconds)
}
// list and folderSelect are declared early so closures below can reference
// them before the widget.NewList / widget.NewSelect calls assign the values.
// list, folderSelect, and applySchedulerState are declared early so closures
// below can reference them before the widgets that assign the values exist.
var list *widget.List
var folderSelect *widget.Select
var applySchedulerState func(bool)
refreshView := func() {
syncFromService()
if applySchedulerState != nil {
// The pause state is Service-owned and can change from outside this
// view (Settings has no such control today, but the event that
// reports it — SchedulerStateChanged — is consumed here rather than
// relying solely on the tap handler's own mirror).
applySchedulerState(svc.Config().Paused)
}
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
dp.logs.Refresh()
@@ -258,26 +271,15 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
refreshView()
})
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
if schedulerPaused {
stopAllText, stopAllIcon = "Enable auto", theme.MediaPlayIcon()
}
schedulerStateText := "Scheduler running"
if schedulerPaused {
schedulerStateText = "Scheduler paused"
}
schedulerState := widget.NewLabel(schedulerStateText)
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. Revert if the save fails.
schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused
dialog.ShowError(err, w)
return
}
if schedulerPaused {
schedulerState := widget.NewLabel("")
stopAllButton := widget.NewButtonWithIcon("", nil, nil)
// applySchedulerState is the one place that draws the pause control and its
// status text from a pause value, so refreshView can drive it from whatever
// the Service reports (including a SchedulerStateChanged the general refresh
// picks up) instead of only the tap handler mirroring its own toggle.
applySchedulerState = func(paused bool) {
schedulerPaused = paused
if paused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Enable auto")
stopAllButton.SetIcon(theme.MediaPlayIcon())
@@ -286,6 +288,17 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
stopAllButton.SetText("Disable auto")
stopAllButton.SetIcon(theme.MediaPauseIcon())
}
}
applySchedulerState(schedulerPaused)
stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. Revert if the save fails.
if err := svc.SetGlobalPause(!schedulerPaused); err != nil {
dialog.ShowError(err, w)
return
}
// refreshView re-derives the pause state from the Service (see
// applySchedulerState above), so it is the single place that draws it.
refreshView()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
+17 -18
View File
@@ -65,43 +65,42 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
// the main thread in both cases, so the engine never mutates Fyne state off
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
recorded, isRecorded := ev.(app.RunRecorded)
errOccurred, isError := ev.(app.ErrorOccurred)
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
fyne.Do(func() {
if isRecorded {
events.add(recorded.Record)
r := recorded.Record
if r.State == "Failed" &&
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
// A type switch does not get compiler-enforced exhaustiveness (see
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
// intentionally fall through to the unconditional refresh() below
// without their own case, since a broad state re-read is all they need.
switch e := ev.(type) {
case app.RunRecorded:
events.add(e.Record)
if e.Record.State == "Failed" &&
(e.Record.Trigger == "Manual" || e.Record.Trigger == "Schedule") &&
svc.ShouldNotifyOnFailure() {
timing := notificationTiming{
JobName: r.JobName,
JobName: e.Record.JobName,
EmittedAt: time.Now(),
}
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
if finished, err := time.ParseInLocation(runRecordTimeLayout, e.Record.Time, time.Local); err == nil {
timing.RunFinished = finished
}
fyne.Do(func() {
timing.UIQueuedAt = time.Now()
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: r.JobName + ": " + r.Detail,
Content: e.Record.JobName + ": " + e.Record.Detail,
})
timing.AfterSendAt = time.Now()
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
fyne.LogError("Failed to write notification timing log", err)
}
})
}
}
if isError {
events.add(newEvent(0, "Service", "Error", errOccurred.Err.Error()))
}
if isJobsLoaded {
case app.ErrorOccurred:
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
case app.JobsLoaded:
// Selecting an existing jobs file replaces the job list without a
// prompt, so History carries the receipt: how many jobs, from where.
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
events.add(newEvent(0, "Service", "Jobs loaded", detail))
}
refresh()
+3 -2
View File
@@ -70,12 +70,13 @@ func Run(startInTray bool) {
a.Run()
return
}
keepInTray = svc.Store().Config.KeepRunningInTray
config := svc.Config()
keepInTray = config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray)
applyTrayBehavior(a, w, keepInTray, false)
// Apply the persisted theme before building content so the window renders in
// the chosen theme from the first frame rather than flashing the default one.
applyTheme(a, svc.Store().Config.Theme)
applyTheme(a, config.Theme)
content, recordStartup := newMainView(w, svc)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
+40 -26
View File
@@ -27,7 +27,14 @@ var settingsCaptions = []string{
}
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
// saved mirrors the config as last persisted (or freshly loaded at
// construction); it is a local copy the closures below compare the form
// against and reassign after a successful save, rather than holding onto
// the live *storage.Store the Service owns (see app.Service.Config).
// paths never changes after construction of this view — AppDir and
// ConfigPath are fixed for the process — so it is read once, not refreshed.
saved := svc.Config()
paths := svc.Paths()
// updateSaveState compares the form to the saved config and enables Save only
// when something differs. It is defined below (once Save and every field
// exist) but declared here so the field change handlers can reference it.
@@ -36,14 +43,14 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// both the initial load and the Cancel/Defaults buttons below.
var loadFields func(domain.Config)
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
startOnLogin.SetChecked(saved.StartOnLogin)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
minimizeToTray.SetChecked(saved.KeepRunningInTray)
autostartStatus := widget.NewLabel("")
trayRestartHint := widget.NewLabel("")
trayRestartHint.Truncation = fyne.TextTruncateClip
refreshAutostartStatus := func() {
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
return
}
@@ -67,15 +74,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
}
minimizeToTray.OnChanged = func(bool) {
refreshAutostartStatus()
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
updateSaveState()
}
refreshAutostartStatus()
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.SetChecked(saved.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() }
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
themeSelect.SetSelected(themeLabel(store.Config.Theme))
themeSelect.SetSelected(themeLabel(saved.Theme))
// Preview the theme the moment it is picked so the choice is visible before
// saving; Save persists it. Reverting the selection reverts the preview, and
// closing without saving falls back to the stored theme on next launch.
@@ -87,20 +94,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
nil,
)
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
executionModeSelect.SetSelected(string(saved.ExecutionMode))
executionModeSelect.OnChanged = func(string) { updateSaveState() }
overlapPolicySelect := widget.NewSelect(
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
defaultTimeout := widget.NewEntry()
defaultTimeout.SetPlaceHolder("0 = no timeout")
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
defaultTimeout.OnChanged = func(string) { updateSaveState() }
jobsFile := widget.NewEntry()
jobsFile.SetText(store.Config.JobsFile)
jobsFile.SetText(saved.JobsFile)
jobsFile.OnChanged = func(string) { updateSaveState() }
// The picker only offers existing files; a jobs file that does not exist yet
// is entered by typing its path, which Save then creates.
@@ -108,7 +115,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
chooseJSONFile(w, jobsFile)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDir.SetText(saved.LogsDir)
logsDir.OnChanged = func(string) { updateSaveState() }
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
@@ -118,13 +125,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// manager. It reveals whatever the field currently holds, so an edit can be
// checked before Save.
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
openFolder(w, settingsFolderPath(store.Paths.AppDir, logsDir.Text))
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogFiles.SetPlaceHolder("0 = unlimited")
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
maxLogFiles.OnChanged = func(string) { updateSaveState() }
maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
// Autostart status sits on its own row beneath the checkbox (rather than
// beside it) so the Application section fits within a half-width column.
@@ -134,13 +143,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
if err != nil || files <= 0 {
settingsStatus.SetText("Max log files must be a positive number")
if err != nil || files < 0 {
settingsStatus.SetText("Max log files must be zero (unlimited) or a positive number")
return
}
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
if err != nil || days <= 0 {
settingsStatus.SetText("Max log age days must be a positive number")
if err != nil || days < 0 {
settingsStatus.SetText("Max log age days must be zero (unlimited) or a positive number")
return
}
if strings.TrimSpace(jobsFile.Text) == "" {
@@ -159,7 +168,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// Build the new config from the form and hand it to the Service, which
// validates it, persists config and jobs to the (possibly new) directory,
// and runs log cleanup so tightened retention limits take effect at once.
config := store.Config
config := saved
config.JobsFile = strings.TrimSpace(jobsFile.Text)
config.LogsDir = strings.TrimSpace(logsDir.Text)
config.MaxLogFiles = files
@@ -171,11 +180,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout
config.Theme = themeFromLabel(themeSelect.Selected)
previousKeepInTray := store.Config.KeepRunningInTray
previousKeepInTray := saved.KeepRunningInTray
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
// different directory), so pick up the fresh copy rather than assuming
// config is exactly what landed.
saved = svc.Config()
paths = svc.Paths()
if err := svc.ApplyAutostart(); err != nil {
refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
@@ -198,7 +212,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// fields compare against their canonical string form; any unparsable text
// counts as a change so the user can click Save and see the validation error.
updateSaveState = func() {
c := store.Config
c := saved
changed := startOnLogin.Checked != c.StartOnLogin ||
minimizeToTray.Checked != c.KeepRunningInTray ||
notifications.Checked != c.NotifyOnFailure ||
@@ -235,17 +249,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
logsDir.SetText(c.LogsDir)
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
settingsStatus.SetText("")
updateSaveState()
}
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
loadFields(store.Config)
loadFields(saved)
})
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
loadFields(domain.DefaultConfig())
@@ -261,7 +275,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
executionModeSelect: executionModeSelect,
overlapPolicySelect: overlapPolicySelect,
defaultTimeout: defaultTimeout,
configPath: store.Paths.ConfigPath,
configPath: paths.ConfigPath,
jobsFile: jobsFile,
jobsFileBrowse: jobsFileBrowse,
logsDir: logsDir,