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
+4 -3
View File
@@ -181,7 +181,7 @@ Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**. 3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
4. Use **Run now** for a one-off manual run without waiting for the schedule. 4. Use **Run now** for a one-off manual run without waiting for the schedule.
5. Use **Pause** on a single job to suspend it without deleting it. 5. Use **Pause** on a single job to suspend it without deleting it.
6. Use **Pause all** as a global stop switch for all scheduled runs. 6. Use **Disable auto** as a global stop switch for all scheduled runs.
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file. 7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications. 8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
@@ -241,8 +241,9 @@ sets one.
## Notifications ## Notifications
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
notification whenever a scheduled or manual run exits with a non-zero exit code. notification whenever a scheduled or manual run ends in the `Failed` state —
The notification shows the job name and the exit code. a non-zero exit code, a timeout, or a process that failed to start.
The notification shows the job name and the failure detail.
## Autostart ## Autostart
+2 -1
View File
@@ -237,8 +237,9 @@ applies to them — and so measure launch latency only.
| `RunCount` | total runs recorded | | `RunCount` | total runs recorded |
| `FailCount` | runs that exited non-zero | | `FailCount` | runs that exited non-zero |
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) | | `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
| `AvgDurationMS` | mean over all runs with a recorded duration | | `AvgDurationMS` | mean over all runs with a recorded duration, computed as `DurationSumMS / TimedRunCount` on every update rather than folded incrementally, so it never disagrees with the exact sum/count average `runner.aggregateLogStats` computes when seeding from logs |
| `MaxDurationMS` | longest recorded run | | `MaxDurationMS` | longest recorded run |
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on `runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
+22 -2
View File
@@ -18,15 +18,35 @@ machine):** average **773 ms** per toast (695874 ms), dominated by PowerShell
cold start. Re-run the script when comparing after a native toast implementation. cold start. Re-run the script when comparing after a native toast implementation.
**App-side timing:** each failure notification appends one line to **App-side timing:** each failure notification appends one line to
`logs/notify-timing.log` (`ms_after_run`, `ms_fyne_do`, `ms_send`, `logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS `ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
toast latency is not included. toast latency is not included. The `.tsv` extension keeps it out of
`runner.CleanupLogs`, which only manages `.log` files — this file is
diagnostic instrumentation for this item, not job output, and should be
removed (or unified with the run-log retention policy under its own knob) once
the native-toast direction below lands and the timing data is no longer
needed.
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or **Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it `SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
needs the same treatment. needs the same treatment.
### Retire the config compatibility shims
Two read-only shims in `storage.loadOrCreateConfig` rewrite an old file into
the current shape on the next save, so each becomes dead the moment a user's
config has been saved once by a build that has it:
- `Config.JobsDir` (pre-0.15, superseded by `Config.JobsFile`).
- `Theme == "default"` (pre-1.0.1, superseded by `ThemeSystem`).
Neither has an expiry. Remove both — the field, the migration branch, and
`TestLoadOrCreateConfigMigratesJobsDir` /
`TestLoadOrCreateConfigMigratesLegacyThemeDefault` — once a release has shipped
long enough that a config file still carrying either old shape is not a
realistic upgrade path GoSentry needs to support.
### Dynamic tray icon toggle ### Dynamic tray icon toggle
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup. Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
+16
View File
@@ -121,6 +121,22 @@ change to their shape has to stay compatible on its own.
an occurrence that fired before the pause/disable. The details pane appends an occurrence that fired before the pause/disable. The details pane appends
", N queued" to the statistics line via `DisplayStats` whenever the count is ", N queued" to the statistics line via `DisplayStats` whenever the count is
non-zero. non-zero.
- **Single-instance arbitration falls back to "start anyway" when the port is
held by something else.** `acquireSingleInstance` (`singleinstance.go`)
binds `127.0.0.1:37653`; if that fails and a dial to the same address does
not answer as GoSentry either, startup continues rather than refusing to
open because of an unrelated local listener. The consequence is deliberate
but worth spelling out: two GoSentry processes can then run two schedulers
against the same `jobs.json` and the same logs directory, each overwriting
the other's saves. Atomic writes (`writeFileAtomic`) prevent a *torn* file
from a concurrent write, but not one process's save clobbering the other's.
- **The single-instance channel is an unauthenticated localhost TCP port.**
Port 37653 accepts one command, `"show"`, from any local process — including
one running as a different user on a shared machine. This is a deliberate
scope choice, not an oversight: the command only raises the existing window,
so the impact of an unwelcome sender is a window popping up, not data
exposure or control. Anything with a larger blast radius on that channel
would need real authentication.
## Out of scope ## Out of scope
+31 -3
View File
@@ -26,6 +26,12 @@ The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
that means the MSYS2 UCRT64 toolchain described in that means the MSYS2 UCRT64 toolchain described in
[DEVELOPMENT.md](DEVELOPMENT.md). [DEVELOPMENT.md](DEVELOPMENT.md).
`src/ui` dominates `go test -race ./...`'s wall time — around 229s in the
2026-08-05 whole-project review, against under 8s for every other package
combined. Budget iteration accordingly: a change confined to `domain`,
`storage`, `runner`, `scheduler`, or `app` gets a fast feedback loop; a `ui`
change does not.
### Manual test commands ### Manual test commands
Run all tests: Run all tests:
@@ -63,6 +69,15 @@ covered by the `app` tests. Measure the engine packages together instead:
go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
``` ```
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
splits the comma-separated `-coverpkg` list on its own and the command fails
with `directory not found`. Use the stop-parsing token, or quote the whole
flag:
```powershell
go test --% -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
```
That figure was 84.4% at the 2026-08-04 review, which is the number to compare That figure was 84.4% at the 2026-08-04 review, which is the number to compare
against before concluding that coverage has slipped. against before concluding that coverage has slipped.
@@ -245,6 +260,8 @@ Tests JSON round-tripping, default generation, and backward compatibility.
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. | | `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. | | `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. | | `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
| `TestNormalizeJobsReassignsDuplicateIDs` | Verifies that a hand-edited `jobs.json` with two entries sharing one ID gets the later duplicates reassigned instead of colliding on one runtime. |
| `TestResolveConfiguredPathCleansAbsolutePaths` | Verifies (Windows only) that forward-slash and backslash spellings of the same absolute path resolve to the same string. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. | | `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. | | `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. | | `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
@@ -376,6 +393,19 @@ Tests log-file cleanup by age and by count.
--- ---
### src/runner/logfile_test.go
**Package:** `runner`
Tests the disambiguating suffix `writeRunLog` applies when two runs land on
the same second.
| Test | Purpose |
|------|---------|
| `TestUniqueLogPathAvoidsCollision` | Verifies repeated calls for the same file name return distinct paths instead of silently overwriting an existing log. |
---
### src/platform/autostart/autostart_windows_test.go ### src/platform/autostart/autostart_windows_test.go
**Location:** `src/platform/autostart/autostart_windows_test.go` **Location:** `src/platform/autostart/autostart_windows_test.go`
@@ -508,8 +538,6 @@ column-width behaviour of the assembled table.
| Test | Purpose | | Test | Purpose |
|------|---------| |------|---------|
| `TestCollectActivityMergesAndSorts` | Verifies per-job logs are merged and sorted by time. |
| `TestCollectActivitySkipsMissingRuntimes` | Verifies missing runtime entries are skipped safely. |
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. | | `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. | | `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. | | `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
@@ -593,7 +621,7 @@ Tests the failure-notification timing diagnostics added in 1.0.2.
| Test | Purpose | | Test | Purpose |
|------|---------| |------|---------|
| `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). | | `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). |
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.log` with its header on first write and appends a row containing the job name. | | `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.tsv` with its header on first write and appends a row containing the job name. |
--- ---
+7
View File
@@ -6,6 +6,13 @@ REM Runs go vet and go test with race detection
REM Move to repository root REM Move to repository root
cd /d "%~dp0\.." cd /d "%~dp0\.."
REM This file is UTF-8 (the ✓/✗ below). cmd.exe reads batch files in the
REM console's active code page, which defaults to the system locale (e.g.
REM CP866 on Russian Windows) rather than UTF-8, so without this the two
REM symbols render as mojibake. Switching the console to UTF-8 first fixes
REM that; >nul silences chcp's own "Active code page" confirmation line.
chcp 65001 >nul
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
REM expected by the Windows build; prepending it keeps the script self-contained REM expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH. REM without permanently changing the user's system PATH.
+12 -5
View File
@@ -1,18 +1,25 @@
package app package app
import ( import (
"fmt"
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop" "gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
) )
// InstallDesktopIcon installs the application's .desktop file and icon on // InstallDesktopIcon installs the application's .desktop file and icon on
// Linux (no-op on other platforms). The resulting icon path is stored in // 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) { func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil { iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes)
s.mu.Lock() if err != nil {
s.store.Paths.DesktopIcon = iconPath s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
s.mu.Unlock() return
} }
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
} }
// AutostartStatus reports whether the platform autostart entry matches the // 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.MaxDurationMS = r.DurationMS
} }
rt.TimedRunCount++ 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 // 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 { if rt.AvgDurationMS != 233 {
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS) 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) { 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.AvgDurationMS = seed.AvgDurationMS
runtime.MaxDurationMS = seed.MaxDurationMS runtime.MaxDurationMS = seed.MaxDurationMS
runtime.TimedRunCount = seed.TimedRunCount 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 // 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. // latest run can be displayed without reopening the log on every repaint.
type RunRecord struct { type RunRecord struct {
Time string `yaml:"time"` Time string
JobID int `yaml:"job_id"` JobID int
JobName string `yaml:"job_name"` JobName string
Trigger string `yaml:"trigger,omitempty"` Trigger string
State string `yaml:"state"` State string
Detail string `yaml:"detail"` Detail string
LogFile string `yaml:"log_file,omitempty"` LogFile string
Output string `yaml:"output,omitempty"` Output string
DurationMS int64 `yaml:"duration_ms,omitempty"` DurationMS int64
} }
+8
View File
@@ -36,6 +36,14 @@ type JobRuntime struct {
// launches that round to 0) increment RunCount but not this. StartOnly runs // launches that round to 0) increment RunCount but not this. StartOnly runs
// otherwise contribute their launch latency. // otherwise contribute their launch latency.
TimedRunCount int 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 // 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") 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 // 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. // avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" 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", 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 { if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write log file: %w", err) 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 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 { func sanitizeFileName(name string) string {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if 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("command:\n")
builder.WriteString(job.Command + "\n\n") builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n") builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments)) builder.WriteString(LogArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue") builder.WriteString("\n\nstart_only:\ntrue")
return builder.String() return builder.String()
} }
+2 -2
View File
@@ -135,8 +135,8 @@ func TestLogArguments(t *testing.T) {
{"--flag\n--value", "--flag\n--value"}, {"--flag\n--value", "--flag\n--value"},
} }
for _, tc := range cases { for _, tc := range cases {
if got := logArguments(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) t.Errorf("LogArguments(%q) = %q, want %q", tc.input, got, tc.want)
} }
} }
} }
+5
View File
@@ -21,6 +21,10 @@ type SeededStats struct {
AvgDurationMS int64 AvgDurationMS int64
MaxDurationMS int64 MaxDurationMS int64
TimedRunCount int 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 // SeedStats scans logsDir once and reconstructs per-job execution-time
@@ -113,6 +117,7 @@ func aggregateLogStats(files []logSummary) SeededStats {
} }
if durationCount > 0 { if durationCount > 0 {
stats.TimedRunCount = durationCount stats.TimedRunCount = durationCount
stats.DurationSumMS = durationSum
stats.AvgDurationMS = durationSum / int64(durationCount) stats.AvgDurationMS = durationSum / int64(durationCount)
} }
return stats return stats
+3
View File
@@ -61,6 +61,9 @@ func TestSeedStatsBasic(t *testing.T) {
if s.AvgDurationMS != 400 { if s.AvgDurationMS != 400 {
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS) 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 // 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 // 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 // decisions that must run before app.Open(). On error it returns the built-in
// default. // 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 { func PeekKeepRunningInTray() bool {
paths, err := ResolvePaths() paths, err := ResolvePaths()
if err != nil { if err != nil {
@@ -207,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
func normalizeJobs(jobs []domain.Job) { func normalizeJobs(jobs []domain.Job) {
next := 1 next := 1
seen := make(map[int]bool, len(jobs))
for index := range jobs { for index := range jobs {
job := &jobs[index] job := &jobs[index]
if job.ID <= 0 { if job.ID <= 0 || seen[job.ID] {
// IDs are assigned only when absent. Existing IDs stay stable because // IDs are assigned only when absent or already claimed by an earlier job
// History and future log associations use them to identify jobs. // 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 job.ID = next
} }
seen[job.ID] = true
if job.ID >= next { if job.ID >= next {
next = job.ID + 1 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. // apply the same rule to a path the user has typed but not yet saved.
func ResolveConfiguredPath(appDir string, path string) string { func ResolveConfiguredPath(appDir string, path string) string {
if filepath.IsAbs(path) { 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 // Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts, // process working directory. This matches ResolvePaths and keeps shortcuts,
+45
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "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) { func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
paths := Paths{ paths := Paths{
-18
View File
@@ -5,8 +5,6 @@ import (
"strings" "strings"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme" "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. // textWidth measures how wide s renders at the theme's current body text size.
func textWidth(s string) float32 { func textWidth(s string) float32 {
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
-27
View File
@@ -6,8 +6,6 @@ import (
"testing" "testing"
"time" "time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/test" "fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget" "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) { func TestHistoryCellText(t *testing.T) {
events := []event{{ events := []event{{
Time: "2026-06-01 12:00:00", 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 // it is re-read here rather than mirrored from the tap handler alone — that is
// what makes this view a consumer of SchedulerStateChanged. // what makes this view a consumer of SchedulerStateChanged.
v.applySchedulerState(v.svc.Config().Paused) 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.updateDetails()
v.dp.logs.Refresh()
v.list.Refresh() v.list.Refresh()
v.syncListSelection() v.syncListSelection()
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// lastJobLogs returns a fresh slice of the most recent activity entries for the // lastJobLogs returns a fresh slice of the most recent activity entries for the
// "Selected job activity" panel. Logs are stored newest-first (see // "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. // capped at maxJobActivityRows.
func lastJobLogs(logs []event) []event { func lastJobLogs(logs []event) []event {
n := len(logs) n := len(logs)
+9 -15
View File
@@ -21,20 +21,11 @@ const runRecordTimeLayout = "2006-01-02 15:04:05"
type job = domain.Job type job = domain.Job
type event = domain.RunRecord type event = domain.RunRecord
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) { func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
svc.InstallDesktopIcon(appID, assets.IconBytes()) // History is session-only: jobs.json never persists JobRuntime.Logs (see
// domain.JobRuntime), so there is nothing to seed the History tab with at
// Build the initial event history from the current runtime state. Jobs and // startup. It starts empty and fills as events arrive.
// runtimes are read here only for this one-time initialization; the jobs view events := newHistoryLog(nil)
// 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))
jobsPanel, refreshJobsView := newJobsView(w, svc) jobsPanel, refreshJobsView := newJobsView(w, svc)
@@ -106,12 +97,15 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
refresh() 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() svc.Start()
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel), container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), 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) tabs.SetTabLocation(container.TabLocationTop)
+2 -2
View File
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
svc := app.NewService(store, nil) svc := app.NewService(store, nil)
defer svc.Stop() defer svc.Stop()
content, _ := newMainView(w, svc) content, _ := newMainView(w, svc, &trayState{})
min := content.MinSize() min := content.MinSize()
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight { if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, 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) svc := newTestService(t)
defer svc.Stop() defer svc.Stop()
content, recordStartup := newMainView(w, svc) content, recordStartup := newMainView(w, svc, &trayState{})
w.SetContent(content) w.SetContent(content)
table := historyTable(t, content) table := historyTable(t, content)
+4 -1
View File
@@ -7,7 +7,10 @@ import (
"time" "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 // notificationTiming captures wall-clock points from a failed run through
// SendNotification. It does not include OS toast display latency — Fyne on // 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" const appID = "ru.mixeme.gosentry.desktop"
// defaultWindowWidth and defaultWindowHeight are the size the window opens at // defaultWindowWidth and defaultWindowHeight are the size the window opens at
// on first launch (later launches restore the last size from preferences). // on every launch. Window size persistence is frozen (see ROADMAP.md), so
// Fyne enforces the assembled content's MinSize as a hard floor over these, so // there is no saved size to restore. Fyne enforces the assembled content's
// they only take effect if the content actually fits within them. // MinSize as a hard floor over these, so they only take effect if the content
// actually fits within them.
const defaultWindowWidth = 1024 const defaultWindowWidth = 1024
const defaultWindowHeight = 660 const defaultWindowHeight = 660
@@ -60,10 +61,7 @@ func Run(startInTray bool) {
w := a.NewWindow("GoSentry " + app.Version) w := a.NewWindow("GoSentry " + app.Version)
setWindowsNotificationIcon() setWindowsNotificationIcon()
prefs := a.Preferences() w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
w.Resize(fyne.NewSize(winW, winH))
svc, err := app.Open() svc, err := app.Open()
if err != nil { if err != nil {
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error()))) w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
@@ -73,13 +71,14 @@ func Run(startInTray bool) {
config := svc.Config() config := svc.Config()
keepInTray = config.KeepRunningInTray keepInTray = config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray) 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 // 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. // the chosen theme from the first frame rather than flashing the default one.
applyTheme(a, config.Theme) applyTheme(a, config.Theme)
content, recordStartup := newMainView(w, svc) content, recordStartup := newMainView(w, svc, tray)
w.SetContent(content) w.SetContent(content)
serveSingleInstance(instanceListener, w) serveSingleInstance(instanceListener, w, tray)
if startHidden { if startHidden {
// Autostart launches intentionally stay hidden, so "window shown" would be // Autostart launches intentionally stay hidden, so "window shown" would be
// a misleading metric. Record a separate startup event for the tray path // 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", "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 // saved mirrors the config as last persisted (or freshly loaded at
// construction); it is a local copy the closures below compare the form // construction); it is a local copy the closures below compare the form
// against and reassign after a successful save, rather than holding onto // 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("") autostartStatus := widget.NewLabel("")
trayRestartHint := widget.NewLabel("") trayRestartHint := widget.NewLabel("")
trayRestartHint.Truncation = fyne.TextTruncateClip 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() { refreshAutostartStatus := func() {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) { if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply") autostartStatus.SetText("Pending: save settings to apply")
return return
} }
ok, message := svc.AutostartStatus() // svc.AutostartStatus() reaches readShortcut on Windows, which spawns
if ok { // powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
autostartStatus.SetText("OK: " + message) // of cold start. Running it off the main thread keeps that from freezing
return // the window on construction and on every checkbox toggle.
} autostartStatus.SetText("Checking...")
autostartStatus.SetText("Problem: " + message) 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) { refreshTrayRestartHint := func(pending bool) {
if pending { if pending {
@@ -142,27 +161,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsStatus := widget.NewLabel("") settingsStatus := widget.NewLabel("")
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text)) // Only the parse itself happens here: a numeric field has to become an int
if err != nil || files < 0 { // before it can go into a domain.Config at all. Everything else — required
settingsStatus.SetText("Max log files must be zero (unlimited) or a positive number") // fields, negative numbers, valid enum values — is Service.UpdateSettings'
return // job (see app.validateConfig), so its error is what the user sees rather
} // than a second copy of the same rules with different wording.
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text)) files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
if err != nil || days < 0 { days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
settingsStatus.SetText("Max log age days must be zero (unlimited) or a positive number") timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
return if filesErr != nil || daysErr != nil || timeoutErr != nil {
} settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
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)")
return return
} }
// Build the new config from the form and hand it to the Service, which // 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 return
} }
refreshAutostartStatus() refreshAutostartStatus()
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true) tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
if previousKeepInTray != config.KeepRunningInTray { if previousKeepInTray != config.KeepRunningInTray {
trayRestartHint.SetText(trayRestartHintText) trayRestartHint.SetText(trayRestartHintText)
} else { } 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 // If the port is unavailable but does not answer as GoSentry, continue
// startup instead of making the application impossible to open because of an // startup instead of making the application impossible to open because of an
// unrelated local listener. In the normal duplicate-start case the dial above // 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 return nil, true
} }
func serveSingleInstance(listener net.Listener, w fyne.Window) { func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
if listener == nil { if listener == nil {
return 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 // Accept runs on its own goroutine, so focusing the window must be
// marshaled onto the main thread like every other widget update. // marshaled onto the main thread like every other widget update.
fyne.Do(func() { fyne.Do(func() {
mainWindowHidden = false tray.hidden = false
w.Show() w.Show()
w.RequestFocus() w.RequestFocus()
}) })
+28 -25
View File
@@ -10,16 +10,19 @@ import (
fynedesktop "fyne.io/fyne/v2/driver/desktop" fynedesktop "fyne.io/fyne/v2/driver/desktop"
) )
// systemTrayRegistered tracks whether this process registered a tray icon at // trayState tracks the two pieces of tray-related process state that Fyne
// launch. Fyne cannot add or remove the icon mid-session, so toggling // itself does not expose: whether this process has registered the tray icon
// KeepRunningInTray in Settings updates close behavior immediately and shows a // (Fyne cannot add or remove it mid-session, so toggling KeepRunningInTray in
// restart hint for the icon itself. // Settings updates close behavior immediately but shows a restart hint for the
var systemTrayRegistered bool // 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
// mainWindowHidden tracks whether the primary window was hidden via the tray // and passes it to every call site of apply — settingsView's Save handler is
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the // the other one — so the coupling between them is explicit instead of hidden
// reveal-on-tray-disable path in applyTrayBehavior. // behind package-level globals that no test can reset.
var mainWindowHidden bool type trayState struct {
registered bool
hidden bool
}
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect." 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) return domain.ResolveStartHidden(cliStartInTray, keepInTray)
} }
// applyTrayBehavior configures window close handling for KeepRunningInTray. // apply configures window close handling for KeepRunningInTray. When
// When revealIfHidden is true and the tray is off, a hidden window is shown so // revealIfHidden is true and the tray is off, a hidden window is shown so the
// the user can still reach the app after disabling the tray mid-session. // user can still reach the app after disabling the tray mid-session.
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) { func (t *trayState) apply(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
if keepInTray && !systemTrayRegistered { if keepInTray && !t.registered {
registerSystemTray(a, w) t.registerSystemTray(a, w)
systemTrayRegistered = true t.registered = true
} }
setWindowCloseBehavior(w, keepInTray) t.setWindowCloseBehavior(w, keepInTray)
if !keepInTray && revealIfHidden && mainWindowHidden { if !keepInTray && revealIfHidden && t.hidden {
mainWindowHidden = false t.hidden = false
w.Show() w.Show()
w.RequestFocus() 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) desk, ok := a.(fynedesktop.App)
if !ok { if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently // 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 quit.IsQuit = true
menu := fyne.NewMenu("GoSentry", menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() { fyne.NewMenuItem("Show", func() {
mainWindowHidden = false t.hidden = false
w.Show() w.Show()
w.RequestFocus() w.RequestFocus()
}), }),
@@ -85,17 +88,17 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
desk.SetSystemTrayWindow(w) desk.SetSystemTrayWindow(w)
} }
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) { func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
if keepInTray { if keepInTray {
w.SetCloseIntercept(func() { w.SetCloseIntercept(func() {
// Closing hides the window instead of quitting because scheduler tools are // Closing hides the window instead of quitting because scheduler tools are
// expected to keep working in the background. The explicit Quit tray item // expected to keep working in the background. The explicit Quit tray item
// remains the way to stop the process. // remains the way to stop the process.
mainWindowHidden = true t.hidden = true
w.Hide() w.Hide()
}) })
return return
} }
mainWindowHidden = false t.hidden = false
w.SetCloseIntercept(nil) w.SetCloseIntercept(nil)
} }