chore: land the remaining low-severity items from the whole-project review

Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every
low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7,
7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11).

Behavioral fixes:
- Reassign duplicate job IDs in a hand-edited jobs.json instead of letting
  two jobs share one runtime, schedule entry, and SeedStats bucket.
- Disambiguate run-log file names that collide within the same second.
- Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an
  incremental integer mean, so it always matches the seeded-from-logs
  average instead of drifting from truncation error.
- Clean absolute paths in ResolveConfiguredPath so two spellings of the
  same jobs file do not trigger a spurious adoption.
- Report InstallDesktopIcon failures through ErrorOccurred instead of
  discarding them silently.
- Move settingsView's blocking AutostartStatus (PowerShell on Windows) off
  the UI thread.
- Give notify-timing.tsv its own extension so CleanupLogs no longer
  manages it as a run log.
- Replace the settingsView Save handler's second copy of validateConfig's
  rules with a bare parse, letting the Service's own error surface.

Cleanups:
- Delete collectActivity, the dead yaml tags on RunRecord, and the
  logArguments/LogArguments alias.
- Fold the two systemTrayRegistered/mainWindowHidden globals into one
  trayState instance Run owns and threads through Settings and the
  single-instance reveal path.
- Fix stale comments/docs: the frozen window-size restore claim, a
  reference to a renamed recordRun, README's "Pause all" and notification
  wording, the PowerShell quoting note for TESTS.md's coverage command,
  and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page.
- Document the single-instance fallback's consequence and the
  unauthenticated instance-channel port in STANDARDS.md; record the
  config-shim retirement plan in ROADMAP.md.

3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no
change needed for those three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 03:35:10 +03:00
parent bd7ebde68e
commit 18da021526
32 changed files with 357 additions and 167 deletions
-18
View File
@@ -5,8 +5,6 @@ import (
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
@@ -26,22 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
}
}
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
var events []event
for _, current := range jobs {
// At startup this is usually empty because jobs.json does not persist
// runtime logs. The function still centralizes the merge for future
// history loading from log metadata.
if rt := runtimes[current.ID]; rt != nil {
events = append(events, rt.Logs...)
}
}
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
return events
}
// textWidth measures how wide s renders at the theme's current body text size.
func textWidth(s string) float32 {
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
-27
View File
@@ -6,8 +6,6 @@ import (
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
@@ -57,31 +55,6 @@ func TestIndexOfID(t *testing.T) {
}
}
func TestCollectActivityMergesAndSorts(t *testing.T) {
jobs := []job{
{ID: 1, Name: "A"},
{ID: 2, Name: "B"},
}
runtimes := map[int]*domain.JobRuntime{
1: {Logs: []domain.RunRecord{{Time: "2026-01-02 10:00:00", JobID: 1}}},
2: {Logs: []domain.RunRecord{{Time: "2026-01-01 09:00:00", JobID: 2}}},
}
got := collectActivity(jobs, runtimes)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].Time != "2026-01-01 09:00:00" || got[1].Time != "2026-01-02 10:00:00" {
t.Errorf("sort order = %v, want ascending by Time", got)
}
}
func TestCollectActivitySkipsMissingRuntimes(t *testing.T) {
jobs := []job{{ID: 1, Name: "A"}}
if got := collectActivity(jobs, nil); len(got) != 0 {
t.Errorf("nil runtimes: got %v, want empty", got)
}
}
func TestHistoryCellText(t *testing.T) {
events := []event{{
Time: "2026-06-01 12:00:00",
+3 -1
View File
@@ -85,8 +85,10 @@ func (v *jobsView) refresh() {
// it is re-read here rather than mirrored from the tap handler alone — that is
// what makes this view a consumer of SchedulerStateChanged.
v.applySchedulerState(v.svc.Config().Paused)
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
// paths do), so refreshing the activity list again here would redraw it twice
// per call.
v.updateDetails()
v.dp.logs.Refresh()
v.list.Refresh()
v.syncListSelection()
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// lastJobLogs returns a fresh slice of the most recent activity entries for the
// "Selected job activity" panel. Logs are stored newest-first (see
// app.Service.recordRun), so the leading entries are the latest; the result is
// app.prependLog), so the leading entries are the latest; the result is
// capped at maxJobActivityRows.
func lastJobLogs(logs []event) []event {
n := len(logs)
+9 -15
View File
@@ -21,20 +21,11 @@ const runRecordTimeLayout = "2006-01-02 15:04:05"
type job = domain.Job
type event = domain.RunRecord
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
svc.InstallDesktopIcon(appID, assets.IconBytes())
// Build the initial event history from the current runtime state. Jobs and
// runtimes are read here only for this one-time initialization; the jobs view
// owns all subsequent state via its own syncFromService closure.
initialJobs := svc.Jobs()
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
for _, j := range initialJobs {
if rt := svc.Runtime(j.ID); rt != nil {
initialRuntimes[j.ID] = rt
}
}
events := newHistoryLog(collectActivity(initialJobs, initialRuntimes))
func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
// History is session-only: jobs.json never persists JobRuntime.Logs (see
// domain.JobRuntime), so there is nothing to seed the History tab with at
// startup. It starts empty and fills as events arrive.
events := newHistoryLog(nil)
jobsPanel, refreshJobsView := newJobsView(w, svc)
@@ -106,12 +97,15 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
refresh()
})
}))
// Installed after Subscribe so a failure reaches History through
// ErrorOccurred instead of being emitted to no listener.
svc.InstallDesktopIcon(appID, assets.IconBytes())
svc.Start()
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc, tray)),
)
tabs.SetTabLocation(container.TabLocationTop)
+2 -2
View File
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
svc := app.NewService(store, nil)
defer svc.Stop()
content, _ := newMainView(w, svc)
content, _ := newMainView(w, svc, &trayState{})
min := content.MinSize()
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
@@ -111,7 +111,7 @@ func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
svc := newTestService(t)
defer svc.Stop()
content, recordStartup := newMainView(w, svc)
content, recordStartup := newMainView(w, svc, &trayState{})
w.SetContent(content)
table := historyTable(t, content)
+4 -1
View File
@@ -7,7 +7,10 @@ import (
"time"
)
const notificationTimingLogName = "notify-timing.log"
// notificationTimingLogName deliberately does not end in .log: runner.CleanupLogs
// only manages .log files in the logs directory, and this diagnostic file
// should not be subject to (or counted against) that retention policy.
const notificationTimingLogName = "notify-timing.tsv"
// notificationTiming captures wall-clock points from a failed run through
// SendNotification. It does not include OS toast display latency — Fyne on
+9 -10
View File
@@ -17,9 +17,10 @@ import (
const appID = "ru.mixeme.gosentry.desktop"
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
// on first launch (later launches restore the last size from preferences).
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
// they only take effect if the content actually fits within them.
// on every launch. Window size persistence is frozen (see ROADMAP.md), so
// there is no saved size to restore. Fyne enforces the assembled content's
// MinSize as a hard floor over these, so they only take effect if the content
// actually fits within them.
const defaultWindowWidth = 1024
const defaultWindowHeight = 660
@@ -60,10 +61,7 @@ func Run(startInTray bool) {
w := a.NewWindow("GoSentry " + app.Version)
setWindowsNotificationIcon()
prefs := a.Preferences()
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
w.Resize(fyne.NewSize(winW, winH))
w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
svc, err := app.Open()
if err != nil {
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
@@ -73,13 +71,14 @@ func Run(startInTray bool) {
config := svc.Config()
keepInTray = config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray)
applyTrayBehavior(a, w, keepInTray, false)
tray := &trayState{}
tray.apply(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, config.Theme)
content, recordStartup := newMainView(w, svc)
content, recordStartup := newMainView(w, svc, tray)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
serveSingleInstance(instanceListener, w, tray)
if startHidden {
// Autostart launches intentionally stay hidden, so "window shown" would be
// a misleading metric. Record a separate startup event for the tray path
+37 -29
View File
@@ -26,7 +26,7 @@ var settingsCaptions = []string{
"GoSentry", "Go", "Fyne", "Repository",
}
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
// 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
@@ -49,17 +49,36 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
autostartStatus := widget.NewLabel("")
trayRestartHint := widget.NewLabel("")
trayRestartHint.Truncation = fyne.TextTruncateClip
// autostartCheckGen guards against an in-flight check's result landing after
// a newer one started (e.g. the user toggles a checkbox again before the
// first check's PowerShell call returns). Both the increment and the compare
// happen on the main/Fyne thread, so this needs no lock of its own.
var autostartCheckGen int
refreshAutostartStatus := func() {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
return
}
ok, message := svc.AutostartStatus()
if ok {
autostartStatus.SetText("OK: " + message)
return
}
autostartStatus.SetText("Problem: " + message)
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
// of cold start. Running it off the main thread keeps that from freezing
// the window on construction and on every checkbox toggle.
autostartStatus.SetText("Checking...")
autostartCheckGen++
gen := autostartCheckGen
go func() {
ok, message := svc.AutostartStatus()
fyne.Do(func() {
if gen != autostartCheckGen {
return
}
if ok {
autostartStatus.SetText("OK: " + message)
return
}
autostartStatus.SetText("Problem: " + message)
})
}()
}
refreshTrayRestartHint := func(pending bool) {
if pending {
@@ -142,27 +161,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsStatus := widget.NewLabel("")
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 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 zero (unlimited) or a positive number")
return
}
if strings.TrimSpace(jobsFile.Text) == "" {
settingsStatus.SetText("Jobs file is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
if err != nil || timeout < 0 {
settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)")
// Only the parse itself happens here: a numeric field has to become an int
// before it can go into a domain.Config at all. Everything else — required
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
// job (see app.validateConfig), so its error is what the user sees rather
// than a second copy of the same rules with different wording.
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
if filesErr != nil || daysErr != nil || timeoutErr != nil {
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
return
}
// Build the new config from the form and hand it to the Service, which
@@ -196,7 +204,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
return
}
refreshAutostartStatus()
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
if previousKeepInTray != config.KeepRunningInTray {
trayRestartHint.SetText(trayRestartHintText)
} else {
+6 -3
View File
@@ -34,11 +34,14 @@ func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
// If the port is unavailable but does not answer as GoSentry, continue
// startup instead of making the application impossible to open because of an
// unrelated local listener. In the normal duplicate-start case the dial above
// succeeds and this process exits after waking the first instance.
// succeeds and this process exits after waking the first instance. The
// consequence of this fallback — two schedulers able to run against the same
// jobs.json and logs directory — is recorded in STANDARDS.md alongside the
// unauthenticated nature of this same port.
return nil, true
}
func serveSingleInstance(listener net.Listener, w fyne.Window) {
func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
if listener == nil {
return
}
@@ -56,7 +59,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
// Accept runs on its own goroutine, so focusing the window must be
// marshaled onto the main thread like every other widget update.
fyne.Do(func() {
mainWindowHidden = false
tray.hidden = false
w.Show()
w.RequestFocus()
})
+28 -25
View File
@@ -10,16 +10,19 @@ import (
fynedesktop "fyne.io/fyne/v2/driver/desktop"
)
// systemTrayRegistered tracks whether this process registered a tray icon at
// launch. Fyne cannot add or remove the icon mid-session, so toggling
// KeepRunningInTray in Settings updates close behavior immediately and shows a
// restart hint for the icon itself.
var systemTrayRegistered bool
// mainWindowHidden tracks whether the primary window was hidden via the tray
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the
// reveal-on-tray-disable path in applyTrayBehavior.
var mainWindowHidden bool
// trayState tracks the two pieces of tray-related process state that Fyne
// itself does not expose: whether this process has registered the tray icon
// (Fyne cannot add or remove it mid-session, so toggling KeepRunningInTray in
// Settings updates close behavior immediately but shows a restart hint for the
// icon itself) and whether the primary window is currently hidden via the tray
// close intercept (Fyne exposes no Window.Visible API). Run owns one instance
// and passes it to every call site of apply — settingsView's Save handler is
// the other one — so the coupling between them is explicit instead of hidden
// behind package-level globals that no test can reset.
type trayState struct {
registered bool
hidden bool
}
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
@@ -27,23 +30,23 @@ func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
}
// applyTrayBehavior configures window close handling for KeepRunningInTray.
// When revealIfHidden is true and the tray is off, a hidden window is shown so
// the user can still reach the app after disabling the tray mid-session.
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
if keepInTray && !systemTrayRegistered {
registerSystemTray(a, w)
systemTrayRegistered = true
// apply configures window close handling for KeepRunningInTray. When
// revealIfHidden is true and the tray is off, a hidden window is shown so the
// user can still reach the app after disabling the tray mid-session.
func (t *trayState) apply(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
if keepInTray && !t.registered {
t.registerSystemTray(a, w)
t.registered = true
}
setWindowCloseBehavior(w, keepInTray)
if !keepInTray && revealIfHidden && mainWindowHidden {
mainWindowHidden = false
t.setWindowCloseBehavior(w, keepInTray)
if !keepInTray && revealIfHidden && t.hidden {
t.hidden = false
w.Show()
w.RequestFocus()
}
}
func registerSystemTray(a fyne.App, w fyne.Window) {
func (t *trayState) registerSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(fynedesktop.App)
if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently
@@ -74,7 +77,7 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
quit.IsQuit = true
menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() {
mainWindowHidden = false
t.hidden = false
w.Show()
w.RequestFocus()
}),
@@ -85,17 +88,17 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
desk.SetSystemTrayWindow(w)
}
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
if keepInTray {
w.SetCloseIntercept(func() {
// Closing hides the window instead of quitting because scheduler tools are
// expected to keep working in the background. The explicit Quit tray item
// remains the way to stop the process.
mainWindowHidden = true
t.hidden = true
w.Hide()
})
return
}
mainWindowHidden = false
t.hidden = false
w.SetCloseIntercept(nil)
}