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:
mixandClaude Sonnet 5 committed 2026-08-07 03:35:10 +03:00
1 parent bd7ebde68e
commit 18da021526
32 files changed
+357 -167

No files matched your search

+12 -5
View File
@@ -1,18 +1,25 @@
package app
import (
"fmt"
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
)
// InstallDesktopIcon installs the application's .desktop file and icon on
// Linux (no-op on other platforms). The resulting icon path is stored in
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
// store.Paths.DesktopIcon so ApplyAutostart can reference it. A failure is
// reported through ErrorOccurred rather than discarded, so the visible symptom
// (a generic dock icon) has an explanation in History instead of none.
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes)
if err != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
return
}
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
}
// AutostartStatus reports whether the platform autostart entry matches the
+2 -1
View File
@@ -253,7 +253,8 @@ func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
rt.MaxDurationMS = r.DurationMS
}
rt.TimedRunCount++
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.TimedRunCount-1) + r.DurationMS) / int64(rt.TimedRunCount)
rt.DurationSumMS += r.DurationMS
rt.AvgDurationMS = rt.DurationSumMS / int64(rt.TimedRunCount)
}
// runningOutput is the placeholder output shown while a job is running, before
+9
View File
@@ -103,6 +103,15 @@ func TestUpdateStats(t *testing.T) {
if rt.AvgDurationMS != 233 {
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
}
// AvgDurationMS must always be exactly DurationSumMS/TimedRunCount — a stored
// sum divided once, not an incremental mean that truncates on every step and
// compounds error over a long-running job.
if rt.DurationSumMS != 700 {
t.Errorf("DurationSumMS = %d, want 700", rt.DurationSumMS)
}
if want := rt.DurationSumMS / int64(rt.TimedRunCount); rt.AvgDurationMS != want {
t.Errorf("AvgDurationMS = %d, want DurationSumMS/TimedRunCount = %d", rt.AvgDurationMS, want)
}
}
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
+1
View File
@@ -151,6 +151,7 @@ func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
runtime.AvgDurationMS = seed.AvgDurationMS
runtime.MaxDurationMS = seed.MaxDurationMS
runtime.TimedRunCount = seed.TimedRunCount
runtime.DurationSumMS = seed.DurationSumMS
}
}
+9 -9
View File
@@ -4,13 +4,13 @@ package domain
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
DurationMS int64 `yaml:"duration_ms,omitempty"`
Time string
JobID int
JobName string
Trigger string
State string
Detail string
LogFile string
Output string
DurationMS int64
}
+8
View File
@@ -36,6 +36,14 @@ type JobRuntime struct {
// launches that round to 0) increment RunCount but not this. StartOnly runs
// otherwise contribute their launch latency.
TimedRunCount int
// DurationSumMS is the running total of every timed run's duration.
// AvgDurationMS is always DurationSumMS/TimedRunCount, computed fresh on each
// update rather than folded incrementally — an incremental integer mean
// truncates on every step, and the error compounds over the life of a job
// that keeps running. A stored sum divided once per update matches the exact
// sum/count average runner.aggregateLogStats computes when seeding from logs,
// so the two no longer disagree about the same run history.
DurationSumMS int64
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
-2
View File
@@ -64,5 +64,3 @@ func LogArguments(arguments string) string {
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func logArguments(arguments string) string { return LogArguments(arguments) }
+22 -2
View File
@@ -23,9 +23,9 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
// by run time. The job name is included for human scanning, but sanitized to
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
path := uniqueLogPath(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, LogArguments(job.Arguments), job.StartOnly, output)
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write log file: %w", err)
}
@@ -70,6 +70,26 @@ func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
return nil
}
// uniqueLogPath returns a path for fileName in dir, appending a disambiguating
// "-2", "-3", … suffix before the extension if the plain name is already
// taken. Two runs of the same job in the same second — a fast manual re-run,
// or a sub-second queue drain — would otherwise share one timestamp and the
// second write would silently overwrite the first.
func uniqueLogPath(dir, fileName string) string {
path := filepath.Join(dir, fileName)
if _, err := os.Stat(path); err != nil {
return path
}
ext := filepath.Ext(fileName)
base := strings.TrimSuffix(fileName, ext)
for n := 2; ; n++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, n, ext))
if _, err := os.Stat(candidate); err != nil {
return candidate
}
}
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
+36
View File
@@ -0,0 +1,36 @@
package runner
import (
"os"
"path/filepath"
"testing"
)
// TestUniqueLogPathAvoidsCollision pins the fix for two runs of the same job
// landing on the same second: without disambiguation the second write would
// silently overwrite the first.
func TestUniqueLogPathAvoidsCollision(t *testing.T) {
dir := t.TempDir()
const name = "20260101-120000_job.log"
first := uniqueLogPath(dir, name)
if first != filepath.Join(dir, name) {
t.Fatalf("first call: got %q, want the plain name", first)
}
if err := os.WriteFile(first, []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
second := uniqueLogPath(dir, name)
if second == first {
t.Fatalf("second call returned the same path as an existing file: %q", second)
}
if err := os.WriteFile(second, []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
third := uniqueLogPath(dir, name)
if third == first || third == second {
t.Fatalf("third call collided with an existing file: %q (existing: %q, %q)", third, first, second)
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ func startOnlyOutput(job domain.Job, pid int) string {
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString(LogArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
+2 -2
View File
@@ -135,8 +135,8 @@ func TestLogArguments(t *testing.T) {
{"--flag\n--value", "--flag\n--value"},
}
for _, tc := range cases {
if got := logArguments(tc.input); got != tc.want {
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
if got := LogArguments(tc.input); got != tc.want {
t.Errorf("LogArguments(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
+5
View File
@@ -21,6 +21,10 @@ type SeededStats struct {
AvgDurationMS int64
MaxDurationMS int64
TimedRunCount int
// DurationSumMS is the running total AvgDurationMS was computed from, folded
// into JobRuntime.DurationSumMS so app.updateStats continues the same exact
// sum instead of restarting from a value it would have to reverse-multiply.
DurationSumMS int64
}
// SeedStats scans logsDir once and reconstructs per-job execution-time
@@ -113,6 +117,7 @@ func aggregateLogStats(files []logSummary) SeededStats {
}
if durationCount > 0 {
stats.TimedRunCount = durationCount
stats.DurationSumMS = durationSum
stats.AvgDurationMS = durationSum / int64(durationCount)
}
return stats
+3
View File
@@ -61,6 +61,9 @@ func TestSeedStatsBasic(t *testing.T) {
if s.AvgDurationMS != 400 {
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
}
if s.DurationSumMS != 1200 {
t.Errorf("DurationSumMS = %d, want 1200", s.DurationSumMS)
}
}
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
+21 -4
View File
@@ -19,6 +19,13 @@ type Store struct {
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
// decisions that must run before app.Open(). On error it returns the built-in
// default.
//
// Despite the name, this can write: loadOrCreateConfig creates gosentry.json
// with defaults on first run, the same as OpenStore does moments later when
// app.Open() parses the now-existing file again. The double parse and the
// write-on-read are both harmless — the second read just sees the file the
// first one created — but worth knowing before adding a third startup path
// that also wants an early look at the config.
func PeekKeepRunningInTray() bool {
paths, err := ResolvePaths()
if err != nil {
@@ -207,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
func normalizeJobs(jobs []domain.Job) {
next := 1
seen := make(map[int]bool, len(jobs))
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
// IDs are assigned only when absent. Existing IDs stay stable because
// History and future log associations use them to identify jobs.
if job.ID <= 0 || seen[job.ID] {
// IDs are assigned only when absent or already claimed by an earlier job
// in this file — a hand-edited jobs.json can carry two entries with the
// same ID, which would otherwise share one runtime, one schedule-cache
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
// because History and future log associations use them to identify jobs.
job.ID = next
}
seen[job.ID] = true
if job.ID >= next {
next = job.ID + 1
}
@@ -241,7 +253,12 @@ func normalizeJobs(jobs []domain.Job) {
// apply the same rule to a path the user has typed but not yet saved.
func ResolveConfiguredPath(appDir string, path string) string {
if filepath.IsAbs(path) {
return path
// Cleaned so two spellings of the same file (forward vs. backslashes, a
// trailing separator) resolve to the same string. UpdateSettings compares
// this against Paths.JobsPath to decide whether the jobs file is changing,
// so an uncleaned path here could trigger a spurious adoption against the
// file the app is already using.
return filepath.Clean(path)
}
// Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts,
+45
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -145,6 +146,50 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
}
}
// TestNormalizeJobsReassignsDuplicateIDs pins the fix for a hand-edited
// jobs.json carrying two entries with the same ID: without reassignment both
// would share one JobRuntime, one schedule-cache entry, and one SeedStats
// bucket, so editing or deleting either would silently affect both.
func TestNormalizeJobsReassignsDuplicateIDs(t *testing.T) {
jobs := []domain.Job{
{ID: 5, Name: "First"},
{ID: 5, Name: "Second"},
{ID: 5, Name: "Third"},
}
normalizeJobs(jobs)
seen := make(map[int]bool, len(jobs))
for _, job := range jobs {
if seen[job.ID] {
t.Fatalf("ID %d assigned to more than one job after normalization: %+v", job.ID, jobs)
}
seen[job.ID] = true
}
if jobs[0].ID != 5 {
t.Errorf("first occurrence should keep its ID: got %d, want 5", jobs[0].ID)
}
if jobs[1].ID == 5 || jobs[2].ID == 5 {
t.Errorf("later duplicates should be reassigned away from 5: got %d, %d", jobs[1].ID, jobs[2].ID)
}
}
// TestResolveConfiguredPathCleansAbsolutePaths pins the fix for two spellings
// of the same absolute path (forward vs. backslashes) resolving to different
// strings: UpdateSettings compares this against Paths.JobsPath as strings to
// decide whether the jobs file is changing, so an uncleaned path here could
// trigger a spurious adoption against the file already in use.
func TestResolveConfiguredPathCleansAbsolutePaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("backslash vs. forward-slash spellings of the same path are a Windows-only ambiguity")
}
got := ResolveConfiguredPath(`C:\app`, "C:/data/jobs.json")
want := ResolveConfiguredPath(`C:\app`, `C:\data\jobs.json`)
if got != want {
t.Errorf("forward-slash and backslash spellings resolved differently: %q vs %q", got, want)
}
}
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir()
paths := Paths{
-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)
}