30 Commits

Author SHA1 Message Date
mix 6a03ea4a20 release: v1.0.3
Bump version, update CHANGELOG, and retake README screenshots
(Jobs, History, Settings) to match the current GUI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 04:14:10 +03:00
mix 4fb9bf6ff3 docs: reconcile the documentation with the code
An audit of every document against the source turned up drift that had
accumulated since the 1.0.2 passes. The screenshot paths README and
DEVELOPMENT still point at are deliberately left alone - the images move
again when they are retaken.

ARCHITECTURE: the jobs_view.go split is six files, not five, since
extracting jobs_view_state.go was never counted; the statistics table
lists TimedRunCount, which the AvgDurationMS formula already referenced;
the store edge of the diagram names methods that exist (LoadJobs and
LoadConfig never did); and startup says that Service.Start is called from
newMainView rather than from Run.

TESTS: three tests had no entry, the latter two being regression tests
for 1.0.2 fixes:

  TestLoadOrCreateConfigPreservesZeroRetentionLimits
  TestWriteJSONReplacesFileAtomically
  TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension

The deliberately-uncovered list now covers everything the profile
actually reports at 0%, so the next redundancy pass does not flag Config,
Paths, SaveJobs, or the isEvent markers as gaps. The coverage section
measures through -coverprofile and says outright that the per-package
percentages -coverpkg prints are not the total - 2.6/8.9/25.5/1.0/61.5
against a real 84.1%.

ROADMAP: the over-the-guideline table was re-measured (service.go is over
it too now, making six), with a note to re-measure rather than trust it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 03:59:48 +03:00
mix 9b7a0565ba Rename 2026-08-07 03:43:32 +03:00
mix da8fe10365 docs: retire the whole-project review plan, correct its CHANGELOG traces
Every item in docs/PROJECT_REVIEW_PLAN.md is now either landed or moved to
ROADMAP.md, so the working document retires the way TEST_REVIEW_PLAN.md did.
Nothing else referenced it.

Two 1.0.2 Internal entries were out of step with what shipped: the timing
diagnostic is written as notify-timing.tsv (kept out of CleanupLogs and off
the UI thread), not notify-timing.log, and the removal of the two per-run
no-op jobs.json rewrites - along with the run-start rollback and the start
error RunDue used to surface - was not recorded at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 03:42:29 +03:00
mix 18da021526 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>
2026-08-07 03:35:10 +03:00
mix bd7ebde68e refactor: extract the Jobs view state, track selection by job ID
Phase 10 of the whole-project review (findings 5.1 and 5.2), folded into
the ROADMAP file-split item as that plan asks.

5.2 was a real defect. `selected` was an index into a snapshot of the jobs
slice, and every path that changed the slice patched it by hand. The one
path that could not — adopting a different jobs file, where the Service
replaces the whole list and the view only hears about it through the
refresh JobsLoaded triggers — left the details pane redrawing from an
index that belonged to the previous list, describing whichever job now sat
there (or clearing when the new list was shorter) while the list highlight
stayed put. The selection is now a job ID; rows are derived from it at
render time, and refresh ends by pointing the highlight at the selected
job, so the two can no longer disagree.

5.1: newJobsView was one 330-line constructor whose dozen closures shared
seven mutable locals. It is now a jobsView struct over a jobsViewState
that owns the snapshot, the folder filter, and the selection — the
invariant that used to be maintained by hand in five places lives in one
place — split across jobs_view.go (construction, refresh, layout),
jobs_view_state.go, jobs_view_list.go, and jobs_view_toolbar.go. The
folder-option rebuild that appeared verbatim in three handlers is one
method.

Behaviour that changed beyond the fix: switching the folder filter keeps
the current selection when the new filter still shows it, instead of
always jumping to the folder's first job.

Docs: ARCHITECTURE records the new file layout and the selection-by-ID
contract; ROADMAP drops jobs_view.go from the over-guideline table and
refreshes the other five numbers (finding 2.4); TESTS documents the new
state test file and the adoption regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:47 +03:00
mix ca2a8c8aa7 fix: accept zero retention limits, retire Store() for typed accessors
Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now
means "keep everything" end to end. runner.CleanupLogs already treated
<= 0 as disabled; validateConfig, the Settings form, and
loadOrCreateConfig's backfill were the only things making that state
unreachable.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:15:19 +03:00
mix 0c8442a8d1 perf: keep file I/O off Service.mu and untie StartOnly from the app context
Phase 7 of the whole-project review (findings 3.2 and 3.3).

Service.mu is the lock the Fyne main thread takes on every Jobs() and
Runtime() call, so anything blocking inside it makes a UI refresh wait on
the disk. Three things did:

- Every SaveJobs/SaveConfig was a marshal, fsync, and rename under mu.
  Writes are now prepared under the lock (Store.PrepareSaveJobs /
  PrepareSaveConfig snapshot the payload and target path) and run after
  it is released. deferSaveLocked takes saveMu while mu is still held, so
  writes still reach the file in the order their snapshots were taken and
  an older snapshot can never land on top of a newer one.
- executeRun ran runner.CleanupLogs under mu after every run. It needs
  only the values already snapshotted into runEnv, so it now runs after
  the unlock — including when the job is gone, since the run still wrote
  a log file that retention covers.
- adoptJobsLocked ran runner.SeedStats under mu, reached from
  UpdateSettings on the UI thread. Seeding moved out into
  applySeededStatsLocked; UpdateSettings now reads the new jobs file and
  seeds its statistics before taking the lock, and re-checks the
  "no jobs-file switch while running" guard once it has it.

SeedStats also opened every log file twice — once to find the job, again
to read the result. readLogSummary reads job_id, state, and duration in
one pass, so each log is opened once.

StartOnly runs were built with exec.CommandContext on the app's lifecycle
context. os/exec keeps a watcher goroutine alive until Wait returns or the
context is done, and StartOnly never calls Wait, so one goroutine leaked
per run and would then try to kill a process whose handle startJobOnly had
already released. The invocation now uses context.Background(), whose nil
Done channel means no watcher is started at all.

Regression tests: TestRunJobStartOnlyLeavesNoContextWatcher (fails with 5
leaked goroutines on the old code), TestConcurrentJobOperationsLeaveTheFileMatchingMemory,
and TestUpdateSettingsSeedsAdoptedJobsFromLogs. STANDARDS gains the
no-I/O-under-mu rule and the "a StartOnly process outlives GoSentry" entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:44:51 +03:00
mix 98c820e3bd perf: cap the History list and fold column widths incrementally
History was appended to on every recorded run and never trimmed, and every
event re-sorted the whole slice and re-measured the Job, Detail and Log
columns across every row. The per-run cost therefore grew with the number of
rows, in exactly the mode the app is designed for: left in the tray for days.

The session History now keeps the newest maxHistoryRows (1000) records, the
way maxJobLogs caps a job's own activity list, and drops the oldest from the
front, zeroing the tail so a dropped record's full captured output is not
kept alive by the backing array. Column widths move into a historyLog value
that folds each new record into the current maxima instead of rescanning.
Widths only grow within a theme, so a column never narrows when a record ages
out; a theme change is the one case that still rescans, because every stored
width was measured at the old text size.

Measured with a throwaway benchmark over 5000 accumulated records: one
refresh went from 15.8 ms to 0.9 ms. At the new cap the full width rescan
alone costs 1.5 ms, so both halves of the fix carry weight.

Plan item 6 of docs/PROJECT_REVIEW_PLAN.md (finding 3.1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:16:02 +03:00
mix 263717874c fix: remove no-op SaveJobs calls, fix PendingRuns lifecycle and cap
Implements items 4-5 of the whole-project review's suggested order
(docs/PROJECT_REVIEW_PLAN.md):

- Drop the three SaveJobs calls in the run lifecycle (startRunLocked,
  executeRun, SetGlobalPause): none of them change a durable Job field,
  everything they touch lives on JobRuntime, which is never persisted.
  Retire TestStartRunLockedRollbackOnSaveFailure with the rollback it
  guarded, since a run can no longer fail to start this way.
- Clear PendingRuns (the "queue" overlap policy's backlog) when a job is
  disabled or the scheduler is globally paused, so resuming or
  re-enabling a job no longer replays a deferred run left over from
  before the pause/disable. Cap it at maxPendingRuns (10) so a job whose
  runs take longer than its own interval stops accumulating an unbounded
  backlog. Surface the queued count in the details pane via DisplayStats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 17:06:55 +03:00
mix 1242b22e4f fix: Windows command quoting, atomic JSON/log writes, restore dropped test
Implements items 1-3 of the whole-project review's suggested order
(docs/PROJECT_REVIEW_PLAN.md):

- Restore TestJobListViewIsCompact, accidentally dropped by 5b0e6fe;
  drop the redundant TestDefaultConfigUsesDetailedJobList row from
  TESTS.md and document the two other doc gaps the review found.
- Fix quoteLeadingWindowsProgramPath to find the earliest file-extension
  match at a word boundary instead of the first extension in list order,
  so a .bat/.cmd command whose argument ends in .exe no longer has its
  whole command line mistaken for the program path.
- Write gosentry.json, jobs.json, and run log files atomically (temp
  file + rename) so a crash or power loss mid-write can no longer leave
  a truncated file. Wire Service.Stop() into the app shutdown path so
  it actually runs, cancelling the run context for in-flight runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:53:59 +03:00
mix 89be009040 docs: remove the whole-project review agenda and its references
REVIEW.md and the /review-project command are gone; other docs no
longer point at them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:38:38 +03:00
mix eaa37a1192 docs: add the whole-project review action plan
Records the findings of a review of the whole codebase against the agenda in
REVIEW.md, with a suggested order and a model recommendation per item.

The project is in good health: the engine layering holds, the Service.mu
locking contract is obeyed, and the UI layout code observes the
measure-don't-hardcode rule with tests to match. The findings concentrate in
three places instead: paths that only surface after the app has run for a
while (unbounded History growth, PendingRuns surviving a pause), durability of
the JSON files (non-atomic writes, no shutdown path), and one Windows quoting
bug reproduced outside the build, where an argument ending in .exe hijacks
program-path detection for a .bat or .cmd job.

Also settles what happened to the two domain tests TESTS.md still documents:
5b0e6fe dropped them while adding two new rows to TESTS.md and leaving the old
rows in place, so the loss was accidental rather than the test-suite review's
doing. Only TestJobListViewIsCompact is worth restoring — the storage default
test already subsumes the other. Neither loss moved coverage; the 84.4% ->
84.0% dip is new 1.0.2 code, PeekKeepRunningInTray at 0%.

Temporary: delete once the items are done or moved to ROADMAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:19:45 +03:00
mix c8a4d31441 release: 1.0.2, tray runtime wiring and Windows notification polish
Move post-1.0.1 changes into a 1.0.2 changelog section and restore 1.0.1
to the shipped release notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:26:39 +03:00
mix 1e3d14bef2 Log failure-notification timing and plan faster Windows toasts.
Append app-side delays to logs/notify-timing.log, add a PowerShell baseline
script (~773 ms), and track native WinRT toasts in ROADMAP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:15:44 +03:00
mix 0aab9d8db6 Register Windows notification icon via metadata after NewWindow.
Fyne toasts read App.Icon without SetIcon, preserving the PE multi-size
window and taskbar icon while giving failure notifications app artwork.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:02:52 +03:00
mix 0a50f3c66b Add disabled failure sample job for testing desktop notifications.
Seed Failure notification test in defaultJobs so new installs can verify
Settings notifications via Run now without scheduler spam.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:50:02 +03:00
mix 1240297cca docs: document platform layer rationale in ARCHITECTURE
Explain why autostart, file manager, shell, and winproc are OS-specific, where compile-time vs runtime branching applies, and rules for new platform code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:46:48 +03:00
mix 5b0e6fe51b Wire KeepRunningInTray to runtime so tray, close, and autostart follow the saved setting.
Autostart entries pass --start-in-tray only when the tray is enabled; Settings warns that the notification icon needs a restart (Fyne limitation).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:44:02 +03:00
mix 5170cc5f99 release: 1.0.1, branded theme default and test-suite review
Move post-v1.0.0 changes out of the 1.0.0 changelog section into 1.0.1,
where they belong: GoSentry theme as default, System label, About GitHub link,
Disable auto inset, README @every docs, and the completed test-suite review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:58:13 +03:00
mix a735bfd116 docs: retire the test review plan, keep its durable findings
Every item in TEST_REVIEW_PLAN.md is done or decided, so the working
document goes as its own header instructed. What outlives it moves to
where a later reviewer will actually look:

- TESTS.md gains the -coverpkg command and the 84.4% baseline (per-package
  figures understate the suite), design principle 9 (redundancy is judged
  by comparing coverage profiles, and identical coverage alone is not
  grounds for deletion), a table of the look-alike tests that are kept
  with the reason each survives, and the list of functions deliberately
  at 0%.
- STANDARDS.md's "Intentional behavior" section points at both lists, so
  the mechanism REVIEW.md describes still reaches them.

Dropped as spent: the per-item checklists, the suggested order, and the
model-selection table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:53:13 +03:00
mix 28f0a0d8e2 test: resolve the thin-test item, decline the runner merge
Item 4 of the test-suite review:

- Delete TestEmitWithNoObserversIsNoop (no assertion; ranging a nil slice
  cannot panic) and TestStoreReturnsWiredStore (a getter returning its
  own field).
- Collapse the four TestFilteredJobIndexes* tests into one table-driven
  TestFilteredJobIndexes, matching TestFilterValue above it.
- Replace the TestMainViewBuilds smoke test with
  TestMainViewRecordStartupAddsHistoryRow, which calls the recordStartup
  closure for both wordings run.go selects between and asserts the rows
  reach the History table through its own cell callbacks. Keeps the
  unique coverage the review identified and adds the !windowShown branch.

Item 5 is declined with measurements: the three RunJob tests cost 0.14 s
combined, so merging them saves ~90 ms while forcing their three
fixtures (including the only Manual trigger) into one. The runner
package's runtime is the two timeout tests, not subprocess spawns.

go vet and go test -race pass for src/app and src/ui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:46:26 +03:00
mix 2ef18e759c test: delete duplicate-coverage tests, fix TESTS.md drift, drop hand-rolled itoa
Items 1-3 of the 2026-08-04 test-suite review: TestCleanupLogsKeepsFilesWithinAgeLimit,
TestRunDueEmptyOverlapInheritsGlobal, and TestSameWindowsPathHandlesSpaces had
byte-identical coverage to an existing test and no assertion the survivor lacked.
storage.defaultJobs, the one accidental 0% coverage gap the review found, is now
covered and TESTS.md corrected to match. seed_test.go's itoa is replaced with
strconv.FormatInt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 22:30:27 +03:00
mix 77bd2db286 docs: recommend a model per item in the test review plan
The deciding factor is the slow feedback loop, not task size: the ui
package needs CGO and the MSYS2 toolchain, and a cold test run took 258s
during the review. Getting an edit right on the first pass is worth more
than generating it faster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:13:04 +03:00
mix 1b6a3604cf docs: add the test-suite review action plan
Records the findings of a review of the whole test suite: three duplicate
tests confirmed by comparing coverage profiles, two inaccuracies in
TESTS.md, a hand-rolled itoa in seed_test.go, and four tests thin enough
to need a decision.

Also lists the pairs that share a coverage profile but assert different
properties, and the functions whose zero coverage is deliberate, so a
later pass does not re-report them. Temporary: delete once the items are
done or moved to ROADMAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:11:50 +03:00
mix cb37377346 Add top inset to the Disable auto row in the Jobs sidebar.
The row sat flush against the tab bar while the VBox gap below was already one theme padding; matching that on top balances the spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:10:11 +03:00
mix b2402f4c72 Rename the Default theme option to System.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:49:06 +03:00
mix 648325a690 Point the About repository link at GitHub.
The Settings About block now links to mixeme/gosentry instead of the private Gitea mirror.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:35:29 +03:00
mix 27927f3ab1 docs: expand README @every schedule syntax
Document supported Go duration units, combinations, cron alternatives for calendar intervals, the one-second tick floor, and cron descriptors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:28:42 +03:00
mix 276539c383 Make the branded GoSentry theme the default.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:18:48 +03:00
74 changed files with 3588 additions and 1189 deletions
-26
View File
@@ -1,26 +0,0 @@
---
description: Review the project as a whole against the agenda in docs/REVIEW.md
---
Perform a whole-project review of GoSentry.
Read [docs/REVIEW.md](../../docs/REVIEW.md) first — it is the agenda, and its
nine sections are the areas to cover. Read [docs/STANDARDS.md](../../docs/STANDARDS.md)
and [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the rules and
contracts the code is checked against.
$ARGUMENTS narrows the review when given — a package path, a file, or the name
of an agenda section. With no arguments, sweep the whole `src/` tree.
Rules for the report:
- Anything listed under "Intentional behavior" in STANDARDS.md is not a finding.
If you believe such an entry is now wrong, say so explicitly as a challenge to
the decision rather than reporting it as a bug.
- Verify before reporting. Read the surrounding code and, where cheap, confirm
the behavior with a test rather than reasoning about it alone.
- Group findings by agenda section, most severe first, each with the file and
line and what would actually go wrong.
- Report honestly that a section is clean rather than inventing something for it.
- Do not fix anything during the review. Report first; apply fixes only when
asked, following "What happens to the findings" in REVIEW.md.
+2 -7
View File
@@ -8,16 +8,11 @@ application service, scheduler, storage, and command runner in one binary.
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
the list of intentional behavior. Do not "fix" anything listed there as
intentional; if a change contradicts it, update the document in the same commit.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts and event flow.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts, event flow, and
§Platform layer (why and where OS-specific code lives).
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
## Reviewing the project
When the user asks for a review of the project (rather than of a specific
diff), follow [docs/REVIEW.md](docs/REVIEW.md) — it is the agenda, and the
`/review-project` command runs the same thing. Do not improvise a checklist.
## Key rules (full list in STANDARDS.md)
- `src/app.Service` is the sole owner of job and runtime state; the UI reads it
+56 -16
View File
@@ -14,8 +14,11 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
<table>
<tr>
<td align="center"><img src="images/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
<td align="center"><img src="images/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
<td align="center"><img src="docs/screenshots/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
<td align="center"><img src="docs/screenshots/screenshot_history.PNG" alt="History tab"><br><em>History tab — past runs with trigger, state, and log file.</em></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="docs/screenshots/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
</tr>
</table>
@@ -33,7 +36,7 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
- Desktop notifications on job failure.
- Windows tray icon: left-click to show the window, right-click for the menu.
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
- Detailed or compact job list, and a default or branded theme; both are remembered.
- Detailed or compact job list, and a system or branded theme; both are remembered.
## Platforms
@@ -51,7 +54,6 @@ GoSentry is built and tested on **Windows** and **Linux**:
- [Roadmap](docs/ROADMAP.md) — planned work larger than a single bug fix
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
- [Standards](docs/STANDARDS.md) — quality rules and intentional behavior
- [Review](docs/REVIEW.md) — what a whole-project review looks at
- [Development](docs/DEVELOPMENT.md) — build instructions, project layout, dependencies
- [Tests](docs/TESTS.md) — test suite layout and how to run it
- [Performance](docs/PERFORMANCE.md) — measured performance findings
@@ -74,7 +76,7 @@ portable application: moving the program folder also moves its configuration.
"execution_mode": "parallel",
"overlap_policy": "skip",
"default_timeout_seconds": 0,
"theme": "default",
"theme": "gosentry",
"job_list_view": "detailed"
}
```
@@ -83,7 +85,7 @@ That is the file GoSentry writes on first run. `default_timeout_seconds` is the
run timeout applied to jobs that do not set their own; `0` means no timeout, and
it is written out even though it is zero, because a missing value and a
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
`theme` is `default` or `gosentry` (the branded teal/amber look), and
`theme` is `system` or `gosentry` (the branded teal/amber look), and
`job_list_view` is `detailed` or `compact` — both are remembered from the
choices made in the app. Keys left at their off value (`start_on_login`,
`paused`) are omitted until they are turned on.
@@ -126,22 +128,55 @@ include the run timestamp and job name:
## Schedules
Interval schedules using Go duration syntax:
GoSentry accepts two schedule forms: fixed `@every` intervals and standard
5-field cron expressions.
### `@every` intervals
Write `@every` followed by a [Go duration](https://pkg.go.dev/time#ParseDuration)
— a positive number with a unit suffix. Units can be combined in one value:
```text
@every 10s
@every 5m
@every 1h30m
@every 10s every 10 seconds
@every 5m every 5 minutes
@every 1h every hour
@every 1h30m every hour and a half (same as @every 90m)
@every 2h45m10s hours, minutes, and seconds combined
```
Standard 5-field cron expressions:
Supported units:
| Unit | Meaning |
|------|---------|
| `ns` | nanoseconds |
| `us`, `µs` | microseconds |
| `ms` | milliseconds |
| `s` | seconds |
| `m` | minutes |
| `h` | hours |
`@every` does **not** support days, weeks, months, or years — those follow a
calendar, not a fixed interval. For “every day at 02:00”, “on the 1st of each
month”, or “once a year”, use a cron expression (below).
The scheduler checks due jobs once per second, so values shorter than `1s` are
accepted but will not fire faster than once a second.
### Cron expressions
Five fields: minute, hour, day-of-month, month, day-of-week.
```text
*/5 * * * * every five minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30
0 0 1 * * first day of every month at midnight
0 0 1 1 * every year on 1 January at midnight
```
Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
`@monthly`, `@yearly` (and `@annually`, `@midnight`).
## Using The App
1. Start GoSentry.
@@ -149,7 +184,7 @@ Standard 5-field cron expressions:
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
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.
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.
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
@@ -168,8 +203,12 @@ loading a new list discards the run state of the old one.
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
it enabled writes an autostart entry using the current executable path.
Autostart entries include `--start-in-tray` so scheduled jobs run after sign-in
without opening the main window.
When **Keep running in the system tray** is also enabled, the entry includes
`--start-in-tray` so scheduled jobs run after sign-in without opening the main
window. With the tray option off, autostart still works but opens the main
window normally. Changing the tray setting updates close behaviour and the
autostart entry immediately; the tray icon itself updates only after you restart
GoSentry (a Fyne limitation — see [docs/ROADMAP.md](docs/ROADMAP.md)).
## Queue Settings
@@ -205,8 +244,9 @@ sets one.
## Notifications
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.
The notification shows the job name and the exit code.
notification whenever a scheduled or manual run ends in the `Failed` state —
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
+3 -1
View File
@@ -17,7 +17,7 @@ import (
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
// image, which the OS then scales to every size it needs — titlebar (~16px),
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
// downscaling the 1254px gosentry-icon-big.png to 16px is muddy, and upscaling
// downscaling the 1254px gosentry-icon-large.png to 16px is muddy, and upscaling
// the 16px icon to 32px is blurry. The fix is to feed each surface a
// size-appropriate source — which differs per platform because each platform
// exposes different icon channels.
@@ -44,6 +44,8 @@ import (
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
// (a multi-size .ico made the tray pick and downscale a larger frame).
// - Desktop toasts: AppMetadata.Icon (set after NewWindow in run.go) feeds
// SendNotification without calling SetIcon, which would override GLFW_ICON.
//
// Linux / other non-Windows (no PE icon resource exists):
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
+103 -21
View File
@@ -41,7 +41,7 @@ flowchart LR
user -->|"edits jobs, settings, runs commands"| ui
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
svc -->|"OpenStore, PrepareSaveJobs, PrepareSaveConfig, LoadJobsFile"| store
store -->|"read/write"| config
store -->|"read/write"| jobs
@@ -57,13 +57,75 @@ flowchart LR
svc -->|"Set / Status via Manager"| autostart
```
## Platform layer
GoSentry ships one binary per target OS. Platform-specific code is not a
workaround for missing cross-platform support — it **is** the cross-platform
strategy: shared interfaces and call sites, with OS-specific implementations
selected at **compile time** (`*_windows.go`, `//go:build linux`, and similar).
Runtime `runtime.GOOS` checks appear only for small UI details (see below), not
for autostart, file-manager integration, or command invocation.
Callers (`app.Service`, `ui`, `runner`) depend on the shared API; they do not
branch on the operating system.
| Package / file | Windows | Linux | Other (`!windows && !linux`) |
| --- | --- | --- | --- |
| `platform/autostart` | Startup-folder `.lnk` shortcut | XDG `~/.config/autostart/gosentry.desktop` | Stub — `Set` returns an error when enabled |
| `platform/desktop` | no-op | Installs `.desktop` + icon under XDG data home | no-op |
| `platform/filemanager` | `explorer` | `xdg-open` | Unsupported — `Open` returns an error |
| `platform/winproc` | `CREATE_NO_WINDOW` / `HideWindow` on child processes | no-op | no-op |
| `runner/invocation_*` | `cmd.exe /S /C` with Windows-safe quoting | `sh -c` | `sh -c` (same as Linux) |
**Why separate implementations are required**
- **Autostart** — each OS defines its own login startup mechanism (shortcut,
XDG Autostart, LaunchAgents on macOS). There is no portable API in Go, Fyne, or
the standard library; a third-party helper would still wrap the same per-OS
code behind an interface.
- **Opening a folder** — the desktop shell exposes no shared “reveal in file
manager” call; each platform invokes its registered handler (`explorer`,
`xdg-open`, `open` on macOS).
- **Command shell** — users expect OS-native semantics (`cmd.exe` batch files,
`%VAR%`, and path rules on Windows; POSIX `sh` on Linux). A single shell for
all platforms would break commands on one side or the other.
- **Hidden console window** — launching a child process from a GUI app can flash
a console on Windows only; Linux and macOS do not need equivalent flags.
**Deliberate platform choices (not OS API limits)**
- **Window and tray icons** — Fyne accepts icons on every platform, but Windows
renders the notification area and titlebar from multi-size `.ico` resources
(embedded via `packaging/windows/gosentry.rc`), while Linux StatusNotifier
trays scale better from a larger PNG. `ui/run.go` and `ui/tray.go` branch on
`runtime.GOOS` for asset selection only.
- **Sample job commands** in `storage/store.go` — demo `echo` lines differ only
because shell quoting rules differ; real jobs are user-authored per platform.
**Adding new platform code**
- Put OS integration in `src/platform/<name>/` with a small shared API, or use
`*_GOOS.go` files in the owning package when the surface is a single function
(as in `runner/invocation_*`).
- Do not scatter `runtime.GOOS` through `app.Service` or UI business logic.
- Unsupported platforms get an explicit stub (return an error or no-op) rather
than silently doing nothing — see `autostart_other.go` and
`filemanager_other.go`.
macOS autostart and file-manager handlers are not implemented yet; see
[ROADMAP.md](ROADMAP.md) for blocked or deferred cross-platform work (for
example window-maximized detection, which would need per-OS native calls).
## Main Flows
1. Startup:
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
events, builds the main window, and calls `Service.Start` to begin the
scheduler loop. On every launch the service seeds per-job run-time statistics
`cmd/gosentry` calls `ui.Run`, which owns the process lifecycle: it calls
`app.Open()` to open the store, load `gosentry.json` and `jobs.json`, and
build the `app.Service`, then hands that Service to `newMainView`
(`mainwindow.go`), which subscribes the UI to service events and calls
`Service.Start` to begin the scheduler loop before assembling the tabs.
`Run` shows the window and, on quit, calls `Service.Stop`.
On every launch the service seeds per-job run-time statistics
from existing log files so the details panel reflects accumulated history
immediately (see §Statistics below).
@@ -77,10 +139,13 @@ flowchart LR
`UpdateSettings` has one extra step: when the configured jobs file changes
and a file already exists at the new path, that file is authoritative. The
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
map, schedule cache, next-run times, and log-seeded statistics around it, and
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
receives the current jobs instead. Adoption drops all runtime state, so it is
refused while a job is running.
map, schedule cache, and next-run times around it, applies the statistics
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
`JobChanged`. A path with no file behind it receives the current jobs instead.
Adoption drops all runtime state, so it is refused while a job is running.
Reading the new file and seeding its statistics both happen before `mu` is
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
running-job check is re-evaluated under the lock before anything is replaced.
3. Scheduled run:
`scheduler.Scheduler` fires a tick every second. On each tick it calls
@@ -104,8 +169,9 @@ flowchart LR
6. History update:
When a run goroutine completes, `Service` updates the job's runtime
(including the statistics aggregate), saves JSON, triggers log cleanup, and
emits `RunRecorded`. The UI observer appends the record to the History tab.
(including the statistics aggregate) under `mu`, then — after releasing it —
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
History rows exist only for the current process session; restarting the app
clears the table (aggregate stats in the details panel are still seeded from
log files).
@@ -113,8 +179,10 @@ flowchart LR
7. Autostart:
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
interface has two implementations: Windows writes a `.lnk` shortcut to the
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
entries pass `--start-in-tray`.
user Startup folder; Linux writes an XDG Autostart `.desktop` file. When
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
off the entry launches the executable without that flag so the main window
opens after sign-in.
8. Error surfacing:
Background errors (failed JSON saves, cleanup errors) are emitted as
@@ -159,9 +227,9 @@ resolves the effective duration under `mu` and `startRunLocked` snapshots it int
resolved duration as an argument, so the runner stays ignorant of the global
config: a positive duration applies the timeout via `context.WithTimeout` and
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
the untimed context and so measure launch latency only, unaffected by the run
timeout.
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
built on `context.Background()` instead — neither the timeout nor app shutdown
applies to them — and so measure launch latency only.
### Run-time statistics
@@ -172,8 +240,10 @@ timeout.
| `RunCount` | total runs recorded |
| `FailCount` | runs that exited non-zero |
| `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 |
| `TimedRunCount` | runs that carried a duration, and so contributed to the aggregates above; a legacy log without a `duration` header counts toward `RunCount` but not this |
| `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
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
@@ -205,16 +275,28 @@ the moment the window opens.
### `jobs_view.go` file structure
The size guideline for a file in this project is ~250 lines.
`src/ui/jobs_view.go` is split across three files along these seams; the view
file itself has grown back over the guideline since — see the split item in
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
`src/ui/jobs_view.go` is split across six files along these seams:
| File | Contents |
|------|----------|
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
The widgets hold no job state of their own: they read `jobsViewState`, which is
the only thing that reads the Service. The **selection is a job ID, not a row
index.** Every path that changes the job list replaces the state's snapshot —
create, delete, and edit from this view's own handlers, adopting a different
jobs file from the Service, which the view only learns about through the refresh
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
job now sits there, so the details pane would describe one job while the list
highlighted another. Rows are derived from the ID at render time
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
list's highlight at the selected job.
### `settings_view.go` file structure
`src/ui/settings_view.go` is split across three files the same way, once its
+245
View File
@@ -2,6 +2,251 @@
All notable GoSentry changes are recorded in this file.
## 1.0.3 - 2026-08-07
**The findings of a whole-project review: durable JSON and log writes, bounded
History and overlap queues, and a Jobs selection that follows the job.**
**Application:**
- Fixed a Windows quoting bug where a job whose **Command** field held a whole
command line (a `.bat`/`.cmd` wrapper followed by an argument that itself
ended in `.exe`) had its entire command line mistaken for the program path,
so the run failed with an unmappable shell error. The program path is now
found by the earliest file-extension match at a word boundary, not the first
extension in list order.
- `gosentry.json` and `jobs.json` (and run log files) are now written
atomically — to a temp file, then renamed into place — so a crash or power
loss mid-write can no longer leave a truncated or empty file. `Service.Stop()`
is now called when the app quits, which also makes the run context
cancellation reach in-flight runs on shutdown.
- Fixed the "queue" overlap policy's backlog (`PendingRuns`): it no longer
survives a global pause or a job being disabled, so resuming or re-enabling a
job can no longer replay a deferred run left over from before the pause/
disable. It is also capped at 10 queued occurrences, so a job whose runs take
longer than its own interval no longer accumulates an unbounded backlog that
then runs back-to-back indefinitely. The job details pane now shows the
queued-run count (", N queued") whenever it is non-zero.
- **Start-only jobs are no longer tied to the application's lifetime.** A job
with *Start only* checked is launched on an uncancelable context, so quitting
GoSentry (or a run context being cancelled) can no longer try to kill a
process it deliberately stopped waiting for. This also removes a goroutine
that leaked on every start-only run and lived until the app exited.
- The History tab no longer grows without bound: it keeps the newest 1000
records and drops the oldest, the way a job's own activity list is capped.
Column widths are also folded in one record at a time instead of being
re-measured across every row on every event, so recording a run no longer
gets slower the longer the app has been running. Measured on 5000 accumulated
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
the width rescan alone accounted for 1.5 ms of every redraw.
- Two runs of the same job that start within the same second no longer share a
log file name. The later one gets a `-2`, `-3`, … suffix instead of silently
overwriting the earlier one's log — reachable with a fast manual re-run or a
sub-second queue drain.
- A hand-edited `jobs.json` in which two entries carry the same `id` no longer
leaves them sharing one runtime, one parsed schedule, and one statistics
bucket; the duplicate is reassigned a free ID on load, as an absent ID always
was.
- The **average run duration** shown in Statistics is now the exact sum divided
by the timed-run count rather than an incrementally folded integer mean. The
old form truncated on every run and the error compounded over a job's life,
so the live figure drifted away from the one rebuilt from log files after a
restart.
- On Linux, a failure to install the `.desktop` file or icon is now reported in
History instead of being discarded, so the visible symptom — a generic dock
icon — has an explanation.
**Jobs:**
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
different jobs file in Settings replaces the whole job list; the details pane
then described whichever job happened to land on the previously selected row —
or went blank if the new list was shorter — while the highlight in the list
stayed where it was. The selection now follows the job itself, and the
highlight and the details pane always describe the same one.
- Switching the **Folder** filter now keeps the current selection when the new
filter still shows that job, instead of always jumping to the folder's first
job.
**Settings:**
- **Max log files and max log age days now accept 0, meaning "keep
everything."** Log cleanup already supported disabling either policy; the
Settings form and the Service validator rejected the value that would have
turned it on. A config that already set either to 0 is no longer silently
rewritten back to the 100/30 defaults on load.
- Opening the tab and saving no longer block the window while the autostart
status is read — on Windows that check shells out to PowerShell, and it now
runs off the UI thread.
- Two spellings of the same absolute **Jobs file** path (mixed separators, a
trailing separator) no longer read as a change of file, so saving no longer
triggers a spurious reload of the file already in use.
**Documentation:**
- Documentation audited against the code. `ARCHITECTURE.md` — the `jobs_view.go`
split is six files, not five (the state extraction was never counted), the
statistics table lists `TimedRunCount`, the store edge of the diagram names
the methods that exist, and startup says where `Service.Start` is actually
called. `TESTS.md` — three tests that had no entry are described
(`TestLoadOrCreateConfigPreservesZeroRetentionLimits`,
`TestWriteJSONReplacesFileAtomically`,
`TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension`), the
deliberately-uncovered list covers everything the profile reports at 0%, and
the coverage figure records how to read the total rather than the per-package
lines. `ROADMAP.md` — the over-the-guideline table was re-measured.
- README's scheduler wording caught up with the 0.11.2 rename of "Pause all" to
**Disable auto**, and its notification description matches what the app sends.
- `STANDARDS.md` records the rules the review settled: no file I/O under
`Service.mu`, the History and pending-run caps, the zero-retention meaning,
that a start-only process outlives GoSentry, the single-instance fallback's
consequence, and the unauthenticated instance-channel port.
- `docs/REVIEW.md` (the whole-project review agenda) and the working plan it
produced are retired now that every item is either landed here or recorded in
`ROADMAP.md`, the way the test review plan was in 1.0.1. `STANDARDS.md` is the
surviving reference.
- The screenshots moved to `docs/screenshots/`.
**Internal:**
- The failure-notification timing diagnostic added in 1.0.2 is now written to
`logs/notify-timing.tsv`. The `.tsv` extension keeps it out of `CleanupLogs`,
which manages only `.log` files, so it is neither deleted by age nor counted
against **Max log files**, and the append now runs off the UI thread.
- `jobs.json` is no longer rewritten twice per run. Starting and finishing a run
touch only `JobRuntime`, which is never persisted, so both saves re-serialised
identical bytes; `SetGlobalPause` did the same alongside its real `SaveConfig`.
Removing them also removes the run-start rollback path and the save failure it
reported, so `RunDue` no longer has a start error to surface at all.
- File I/O no longer happens while `Service.mu` is held — that is the lock the
UI thread takes on every job and runtime read, so a JSON write, the
post-run log cleanup, or the startup log scan used to make a UI refresh wait
on the disk. Saves are now prepared under the lock and written after it is
released, in preparation order, so `jobs.json` still ends up matching the
in-memory list. Seeding statistics from logs also opens each log file once
instead of twice.
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
`jobs_view_list.go`, and `jobs_view_toolbar.go`, joining the existing
`jobs_view_details.go` and `jobs_view_helpers.go`. What used to be one
330-line constructor whose dozen closures shared seven mutable locals is now
widgets reading one named state object — which is what made the selection fix
above a change in one place instead of five.
- `Service.Store()` is replaced by typed `Service.Config()` and `Service.Paths()`
accessors that copy under the lock, so the UI no longer reaches into a shared
`*storage.Store`. The Jobs pause control is now driven by `refreshView`
reading `svc.Config().Paused` on every event, making it a real consumer of
`SchedulerStateChanged`, and the main window's event listener is a type switch.
- Dead code removed: `collectActivity`, the `yaml` tags on `RunRecord`, the
`logArguments`/`LogArguments` alias, the redundant package-level
`SetAutostart`/`AutostartStatus` functions, and the Settings Save handler's
second copy of the Service's validation rules. The
`systemTrayRegistered`/`mainWindowHidden` globals are one `trayState` value
that `Run` owns and threads through.
- `scripts/test.bat` no longer prints mojibake for its checkmarks under a
non-UTF-8 code page.
## 1.0.2 - 2026-08-05
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
the app icon (experimental).**
**Application:**
- **Keep running in the system tray** now controls behaviour: with the tray on
(default), closing the window hides it and autostart uses `--start-in-tray`;
with the tray off, closing quits the app and autostart opens the main window.
- Saving a tray change updates close behaviour and the autostart entry
immediately. The notification-area icon follows the saved value after a
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
remove the icon mid-session).
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
the window when the tray setting is off — saved config wins over the CLI flag.
- On Windows, failure toasts can show the app icon: after `NewWindow`,
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
`SetIcon`, which would override the PE multi-size window/taskbar icon.
**Jobs:**
- New disabled example *Failure notification test* (folder Examples). Run it
manually to trigger a failed run and verify Settings → Notifications without
waiting on the scheduler.
**Documentation:**
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
for adding platform code.
**Internal:**
- App-side failure-notification timing is appended to `logs/notify-timing.log`
for diagnosing toast delay (OS latency excluded).
`scripts/measure-windows-toast.ps1` measures the PowerShell baseline on
Windows.
## 1.0.1 - 2026-08-04
**The branded theme is the default, Fyne's built-in theme is System, and the
test suite is leaner.**
**Settings:**
- **The branded GoSentry theme is now the default.** Fresh installs, the
**Defaults** button, and configs that omit `theme` all open in the teal/amber
look; users who prefer Fyne's built-in theme can still pick **System** in
Settings.
- The Fyne built-in theme option is labelled **System** (stored as `"system"`);
configs that still say `"default"` are read as System and rewritten on save.
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
the private Gitea mirror.
**Jobs:**
- The **Disable auto** row gained a top inset matching the gap below it, so it
no longer sits flush against the tab bar.
**Documentation:**
- The **README Schedules** section now documents `@every` in full: supported Go
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
`time.ParseDuration`, the fact that days/months/years belong in cron rather
than `@every`, the one-second scheduler tick floor, cron examples for monthly
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
**Tests:**
- Three tests with byte-identical coverage to an existing test and no unique
assertion are gone: `TestCleanupLogsKeepsFilesWithinAgeLimit`,
`TestRunDueEmptyOverlapInheritsGlobal` (its one unique setup guard moved into
`TestRunDueQueueRerunsAfterFinish`), and `TestSameWindowsPathHandlesSpaces`
(its spaces case folded into `TestSameWindowsPathIgnoresCaseAndQuotes`'s
fixture). So are two that carried no assertion at all:
`TestEmitWithNoObserversIsNoop` and `TestStoreReturnsWiredStore`.
- The four `TestFilteredJobIndexes*` tests are one table-driven
`TestFilteredJobIndexes`, matching `TestFilterValue` above it.
- `TestMainViewBuilds` is replaced by `TestMainViewRecordStartupAddsHistoryRow`,
which exercises the `recordStartup` closure for both wordings `run.go` selects
between and asserts the rows reach the History table through its cell callbacks,
including the `!windowShown` branch.
- `storage.defaultJobs` — the one accidental 0%-coverage gap the review
found — is now exercised by
`TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun`, which also corrects
`docs/TESTS.md`: `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` never
touched jobs, so the "and a sample job" half of its old description was
wrong.
- `src/runner/seed_test.go`'s hand-rolled `itoa` — 18 lines of digit-by-digit
conversion in a file that already imports `strconv` — is replaced with
`strconv.FormatInt`.
- **`docs/TESTS.md`** records the `-coverpkg` command and the 84.4% baseline
(per-package figures understate the suite), design principle 9 (redundancy is
judged by comparing coverage profiles, and identical coverage alone is not
grounds for deletion), a table of the look-alike tests that are kept with the
reason each survives, and the list of functions deliberately at 0%.
- **`docs/STANDARDS.md`**'s "Intentional behavior" section points at both lists,
so the mechanism `docs/REVIEW.md` describes still reaches them. The spent test
review plan is retired.
## 1.0.0 - 2026-07-27
**The window opens at the size it asks for, and the Jobs divider can be
+3 -2
View File
@@ -280,8 +280,9 @@ Before tagging:
1. Bump `src/app/version.go`. The tag must match it exactly.
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
3. Retake the README screenshots (`docs/screenshots/screenshot_jobs.PNG`,
`docs/screenshots/screenshot_settings.PNG`,
`docs/screenshots/screenshot_history.PNG`) if the GUI changed its appearance. This is
easy to forget because nothing fails without it: `README.md` is packaged
inside every release archive and is what the forge shows on the project page,
so a stale shot advertises an application that no longer exists. Take them
-86
View File
@@ -1,86 +0,0 @@
# GoSentry — Review Agenda
What to look at when reviewing the project as a whole, as opposed to a single
diff. This is the agenda; the rules a review checks against live in
[STANDARDS.md](STANDARDS.md) and [ARCHITECTURE.md](ARCHITECTURE.md).
Scope note: a normal pull-request review checks the change. This agenda is for
a periodic sweep of the whole codebase, so a pass may legitimately end with
"nothing to report" on most items.
## 1. Architecture and project structure
Does the code still match the package map and the event flow in
[ARCHITECTURE.md](ARCHITECTURE.md)? Watch for the boundaries that matter here:
`app.Service` as the sole owner of job and runtime state, the UI reading it
through typed events, `domain` staying free of I/O, and platform-specific code
staying behind the `platform/*` interfaces.
## 2. Complexity against the size of the project
GoSentry is a single-process desktop app with two direct dependencies. Flag
abstraction that is not paying for itself: interfaces with one implementation
and no test seam, indirection added for a use case nobody has asked for, a new
dependency where thirty lines of standard library would do. Also check the
opposite direction — files that have grown past the size guideline in
ARCHITECTURE and should be split the way `jobs_view.go` was.
## 3. Code quality
The checkable rules are in [STANDARDS.md](STANDARDS.md) — error handling, unit
tests for pure helpers, regression tests for fixes, `fyne.Do` for updates off
the main thread. Beyond them: concurrency around `Service.mu`, goroutines whose
lifetime is not obvious, and error paths that report something less useful than
what they caught.
## 4. Documentation and comments
Does every documented behavior still exist, and does every non-obvious behavior
get documented? Check the doc set against the code: README (user-facing
behavior and config keys), ARCHITECTURE (packages and flows), STANDARDS
(rules and intentional behavior), DEVELOPMENT (build), TESTS, PERFORMANCE,
CHANGELOG (an entry per notable change). For comments, the bar is *why*, not
*what* — a comment restating the line below it is noise; an unexplained
workaround is a finding.
## 5. Readability and maintainability
Read a package as someone who has not seen it before. Can the next change be
made without reverse-engineering? Naming that matches the domain vocabulary,
functions that do one thing, and control flow that does not need a diagram.
## 6. Logical errors
Correctness independent of style: scheduling and timing edge cases (overlap
policy, sequential mode, pause interactions), off-by-one and boundary handling,
zero values that mean something (see the timeout rules in STANDARDS), state
that can be observed mid-update, and error paths that leave state inconsistent.
## 7. Legacy code and migrations
The app has no database, so migration means file compatibility: `gosentry.json`
and `jobs.json` written by an older version must keep working. Check that new
`Config` fields are backward compatible, that normalization happens in one
place, and that values which are meaningful zeros are not normalized away. Also
look for code kept alive only for a case that no longer exists.
## 8. Undocumented or under-documented contentious decisions
Any decision a future reader would question needs its reasoning recorded where
it lives: a comment at the code, an entry in the "Intentional behavior" section
of [STANDARDS.md](STANDARDS.md), or — when the work is deferred rather than
decided — a note in [ROADMAP.md](ROADMAP.md), which is where the frozen
window-size work keeps its rationale.
## 9. Other improvement proposals
Anything that does not fit above: build and release ergonomics, test coverage
gaps, dependency health, UX rough edges.
## What happens to the findings
- A defect → fix it, with a regression test when severity is medium or higher.
- Behavior that turns out to be deliberate → record it under "Intentional
behavior" in [STANDARDS.md](STANDARDS.md) so it is not re-reported.
- Work larger than a single fix → [ROADMAP.md](ROADMAP.md), with the reasoning.
- A new rule the review establishes → [STANDARDS.md](STANDARDS.md).
+82 -19
View File
@@ -5,6 +5,64 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
## Open Items
### Faster Windows failure notifications
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
writes a short script to `%TEMP%` and runs it through a **new PowerShell
process** (`app/app_windows.go`), which typically adds **13 seconds** of cold
start before the toast appears. GoSentry's own path from run completion through
`SendNotification` is much smaller and is logged separately.
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
machine):** average **773 ms** per toast (695874 ms), dominated by PowerShell
cold start. Re-run the script when comparing after a native toast implementation.
**App-side timing:** each failure notification appends one line to
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
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
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
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
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
There is no supported way to register or remove the notification-area icon
after the process is running.
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
autostart entry update immediately when the user saves Settings; the tray icon
follows the saved value on the next launch. Settings shows a restart hint when
the tray checkbox changes.
Revisit when Fyne adds a documented API for mid-session tray registration, or
when a stable cross-platform approach exists without reaching into driver
internals. Until then, removing the restart hint and applying the icon on save
is blocked.
### Update check from GitHub releases
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
@@ -85,24 +143,25 @@ Design notes / open questions:
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
and records the `jobs_view.go` and `settings_view.go` splits as the worked
examples. Six non-test files are over it at 1.0.0, including both files that
were already split once:
examples. `jobs_view.go` was split again in 1.0.3 — into view, state, list, and
toolbar — because the selection defect it carried was a symptom of the size
(one 330-line constructor over seven shared locals). Six non-test files are
over the guideline:
| File | Lines |
|------|-------|
| `src/app/operations.go` | 490 |
| `src/ui/jobs_view.go` | 355 |
| `src/app/run.go` | 287 |
| `src/ui/history_view.go` | 282 |
| `src/ui/settings_view.go` | 277 |
| `src/storage/store.go` | 265 |
| `src/app/operations.go` | 529 |
| `src/storage/store.go` | 382 |
| `src/ui/history_view.go` | 355 |
| `src/ui/settings_view.go` | 326 |
| `src/app/run.go` | 275 |
| `src/app/service.go` | 252 |
This is deliberately deferred to the next whole-project review rather than done
piecemeal: [REVIEW.md](REVIEW.md) already asks item 2 to look for exactly this,
a split touches every reader of the file, and doing all six in one pass keeps
the seams consistent instead of settling them six different ways. Splitting is
The remaining six are deliberately deferred rather than done piecemeal: a
split touches every reader of the file, and doing them in one pass keeps the
seams consistent instead of settling each one its own way. Splitting is
also the kind of change that reads as pure movement while quietly dropping a
function, so it wants one careful pass, not six hurried ones.
function, so it wants one careful pass, not a hurried one per file.
Seams visible today, as a starting point rather than a decision:
@@ -114,13 +173,17 @@ Seams visible today, as a starting point rather than a decision:
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
`historyColumnWidths`) are pure, already unit-tested, and independent of the
table they size.
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
split has to break that function up (list template, toolbar handlers,
assembly) rather than move whole functions. Larger judgement call than the
others.
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
load/normalize path are three separate concerns in one file.
- **`run.go`**, **`settings_view.go`**, **`service.go`** — barely over. Worth
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
alone rather than splitting for the sake of the number.
alone rather than splitting for the sake of the number. The counts above move
a few lines either way with any edit, so re-measure before acting on them
rather than treating the table as current.
The `jobs_view.go` pass is the worked example for the rest: the constructor was
broken up along the state it shared, not along line count, and the split landed
with the selection fix rather than promising it separately.
Scope note: the guideline is about source files. Test files are much larger and
that is fine — a table-driven test file grows with the cases it covers.
+73 -2
View File
@@ -1,8 +1,7 @@
# GoSentry — Standards
Quality rules and intentional behavior for contributors. Package contracts live
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md);
what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
## Code quality
@@ -12,6 +11,15 @@ what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
- Fixes with severity ≥ medium → regression test.
- Documented intentional behavior → section below, not a backlog bug.
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
log-directory scan, or a pass over every log header inside it makes a UI
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
writes still reach the file in the order their snapshots were taken; log
cleanup and `runner.SeedStats` run from plain snapshots.
- A size that must follow the theme is **measured at build time, not written as
a pixel constant.** `theme.Padding()` and text metrics depend on the running
app's theme, text size, and DPI, so a hand-tuned number is only correct for
@@ -63,9 +71,72 @@ change to their shape has to stay compatible on its own.
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
global default, 0 = no timeout, positive = seconds). Neither zero may be
normalized away on load — 0 is a value, not a missing field.
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
negative count is rejected), and `storage.loadOrCreateConfig` no longer
backfills 0 to 100 / 30 — a config written before either field existed still
picks up the default because `json.Unmarshal` leaves an absent key holding
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
relies on.
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
launch something and let go of it, so the runner builds that invocation on
`context.Background()`, not on the application's lifecycle context: quitting
GoSentry (or cancelling a run) does not stop a process it started this way, and
`Service.Stop()` reaches only jobs the runner is still waiting on. The
uncancelable context is also what keeps `os/exec` from leaving a watcher
goroutine per run — it only starts one when the context can be done, and
`StartOnly` never calls `Wait` to end it.
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
current process. Log files on disk feed aggregate statistics via `SeedStats`
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
- **History is capped and its columns only widen.** The tab keeps the newest
`maxHistoryRows` records and drops the oldest, the way `maxJobLogs` caps a
job's own activity list — an app left in the tray records thousands of runs a
day, each carrying the run's full captured output. Column widths are folded in
one record at a time instead of rescanned from every row, so a column never
narrows when a record ages out: the rows on screen were laid out against the
wider value. A theme change is the one case that rescans, because every stored
width was measured at the old text size.
- Several tests share a coverage profile with another test on purpose, and a few
functions sit at 0% on purpose. Both lists live in
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
coverage gap as an oversight.
- **`KeepRunningInTray` controls tray and close behavior.** When enabled (the
default), the app registers a system tray icon at launch, closing the window
hides it, and autostart passes `--start-in-tray`. When disabled, no tray icon
is registered at launch, closing the window quits the app, and autostart opens
the main window. Toggling the setting in Settings updates close behavior and
rewrites the autostart entry immediately; the tray icon itself follows the
saved value only after a restart because Fyne has no API to add or remove it
mid-session (see [ROADMAP.md](ROADMAP.md)).
- **`--start-in-tray` defers to config.** A stale autostart shortcut that still
passes the flag does not hide the window when `KeepRunningInTray` is off.
- **`JobRuntime.PendingRuns` (the "queue" overlap policy's backlog) is capped at
`maxPendingRuns` (10) and cleared on pause or disable.** A job whose runs take
longer than its interval stops accumulating backlog once the cap is hit —
further overlaps are dropped like the "skip" policy until the backlog drains
below the cap. `SetGlobalPause(true)` and `SetEnabled(id, false)` both zero
the counter, so resuming or re-enabling a job never replays a deferred run for
an occurrence that fired before the pause/disable. The details pane appends
", N queued" to the statistics line via `DisplayStats` whenever the count is
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
+166 -26
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
[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
Run all tests:
@@ -55,6 +61,37 @@ go test -coverprofile=coverage.out ./src/runner
go tool cover -html=coverage.out
```
Per-package coverage understates the suite, because several packages are
exercised from another one's tests — `domain.NewRuntime`, for instance, is
covered by the `app` tests. Measure the engine packages together instead:
```bash
go test -coverprofile=cover.out -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 — and note that `--%` swallows the rest of the line, so the profile has to
be read by a second command:
```powershell
go test --% -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
```
The total is the last line of the profile summary. It is **not** any of the
per-package lines `go test` prints: with `-coverpkg` spanning five packages,
each of those reports only what that one package's tests reached across the
whole set, so all five are far below the real figure.
```powershell
go tool cover -func=cover.out | Select-Object -Last 1
```
That total was 84.4% at the 2026-08-04 review and 84.1% at the 2026-08-07
documentation audit — the number to compare against before concluding that
coverage has slipped.
---
## Test Files Overview
@@ -82,13 +119,13 @@ Tests schedule parsing and validation.
**Package:** `domain`
Tests the normalization rule shared by every consumer of the jobs-list density
setting.
Tests autostart argument helpers and the jobs-list density normalization rule.
| Test | Purpose |
|------|---------|
| `TestAutostartArguments` | Verifies `AutostartArguments` returns `--start-in-tray` when the tray is enabled and an empty string when it is off. |
| `TestResolveStartHidden` | Verifies hidden autostart requires both the CLI flag and `KeepRunningInTray`. |
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
---
@@ -102,7 +139,6 @@ Tests `Service` construction and the state-accessor contract.
|------|---------|
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. |
---
@@ -126,6 +162,7 @@ Tests all mutating operations on the Service, scheduler integration, and setting
| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. |
| `TestSetEnabledNotFound` | Verifies that `SetEnabled` returns an error for an unknown job ID. |
| `TestSetEnabledToggles` | Verifies that `SetEnabled` flips the enabled flag and persists the change. |
| `TestSetEnabledClearsPendingRuns` | Verifies that disabling a job zeroes a `PendingRuns` backlog it was carrying, so re-enabling it later does not replay a stale deferred run. |
#### Global pause / run-now / run-due
@@ -155,6 +192,8 @@ Tests all mutating operations on the Service, scheduler integration, and setting
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
@@ -175,14 +214,13 @@ and scheduler edge cases using injected `runJob` and `primeDue`.
| `TestRunDueParallelStartsAllDueJobs` | Parallel mode: both due jobs enter the runner before either completes. |
| `TestRunDueSequentialSerializes` | Sequential mode: job 2 waits until job 1 finishes. |
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish. |
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish; also covers an empty per-job policy inheriting the global default. |
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
| `TestRunDueQueueCapsPendingRuns` | Regression: `PendingRuns` stops growing at `maxPendingRuns` instead of accumulating without bound for a job that never keeps up with its schedule. |
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
| `TestRunDueEmptyOverlapInheritsGlobal` | Empty per-job policy inherits the global default. |
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused, and pausing clears the backlog rather than leaving it to fire a stale deferred run on resume. |
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
---
@@ -196,7 +234,6 @@ Tests the event-emission and observer-subscription machinery.
| Test | Purpose |
|------|---------|
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
| `TestEmitWithNoObserversIsNoop` | Verifies that emitting an event with no observers does not panic. |
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
---
@@ -212,8 +249,8 @@ Tests display-formatting helpers used by the UI.
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
| `TestDisplayFolder` | Verifies that an empty folder string shows "(No folder)". |
| `TestDisplayArguments` | Verifies that an empty arguments string shows "(none)". |
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
| `TestDisplayInvocation` | Verifies that the full invocation display string combines command and arguments with spacing. |
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
@@ -234,13 +271,19 @@ Tests JSON round-tripping, default generation, and backward compatibility.
| `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. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
| `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. |
| `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. |
| `TestLoadOrCreateConfigPreservesZeroRetentionLimits` | Verifies that `max_log_files` / `max_log_age_days` of 0 read back as 0 ("keep everything") instead of being backfilled to the 100 / 30 defaults — a field the file sets is not the missing-field case. |
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
| `TestWriteJSONReplacesFileAtomically` | Pins the durability fix: `writeJSON` replaces the destination through a temp file and a rename rather than truncating it in place, and leaves no temp file behind. |
---
@@ -308,6 +351,7 @@ Tests command execution, exit code handling, output capture, and the run timeout
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
---
@@ -324,6 +368,7 @@ Tests the Windows shell invocation and hidden-window flags.
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
| `TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension` | Regression: the program path ends at the *earliest* extension match sitting at a token boundary — not the first extension in `.exe`/`.cmd`/`.bat`/`.com` list order, and not a substring inside another word — so a `.bat` wrapper followed by an `.exe` argument still quotes only the wrapper. |
---
@@ -354,8 +399,7 @@ Tests log-file cleanup by age and by count.
| Test | Purpose |
|------|---------|
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. |
| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. |
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted and files within the limit are retained. |
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
@@ -363,6 +407,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
**Location:** `src/platform/autostart/autostart_windows_test.go`
@@ -372,13 +429,14 @@ Tests Windows autostart via shortcuts in the Startup folder.
| Test | Purpose |
|------|---------|
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. |
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive, handles quote marks, and matches paths containing spaces. |
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
| `TestCreateStartupShortcutWithoutTrayFlag` | Verifies that autostart shortcuts omit `--start-in-tray` when the tray setting is off. |
| `TestAutostartStatusRequiresMatchingTrayFlag` | Verifies `AutostartStatus` reports a problem when the shortcut arguments do not match `KeepRunningInTray`. |
---
@@ -392,6 +450,19 @@ Tests Linux autostart via XDG Desktop Entry files.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
| `TestLinuxAutostartWithoutTrayFlag` | Verifies that the desktop entry omits `--start-in-tray` when the tray setting is off. |
---
### src/ui/tray_test.go
**Package:** `ui`
Tests startup helpers for tray and autostart interaction.
| Test | Purpose |
|------|---------|
| `TestResolveStartHiddenUsesDomainHelper` | Verifies the UI startup helper stays aligned with `domain.ResolveStartHidden`. |
---
@@ -437,21 +508,41 @@ widgets are assembled.
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. |
| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. |
| `TestFilteredJobIndexesAll` | Verifies that the "All" filter returns indexes for every job. |
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
| `TestFilteredJobIndexes` | Table: verifies the "All" filter returns every index, a named folder returns only its own jobs, "No folder" matches empty and blank folder fields, and an empty job list yields no indexes. |
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
---
### src/ui/jobs_view_state_test.go
**Package:** `ui`
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
folder filter, and the ID-based selection. No Fyne app is built — the state
touches no widgets, so these run in milliseconds.
| Test | Purpose |
|------|---------|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
---
### src/ui/history_view_test.go
**Package:** `ui`
@@ -461,8 +552,6 @@ column-width behaviour of the assembled table.
| 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`. |
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
@@ -473,6 +562,10 @@ column-width behaviour of the assembled table.
| `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. |
| `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. |
| `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. |
| `TestHistoryLogCapsRecords` | Regression guard for the unbounded History list: the log keeps the newest `maxHistoryRows` records, drops the oldest from the front, and trims a list handed in already over the cap. |
| `TestHistoryLogWidthsMatchAFullScan` | Verifies the incremental column widths equal a full rescan while every measured record is still present — the cheaper path must not clip what the old one showed. |
| `TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut` | Verifies a column keeps its width after the record that set it is dropped by the cap, since the rows on screen were laid out against it. |
| `TestHistoryLogRescansOnThemeChange` | Verifies a theme change falls back to a full rescan, the one case the incremental fold cannot handle because every stored width was measured at the old text size. |
---
@@ -500,6 +593,7 @@ Tests the theme-derived sizing helpers in `layout.go`.
| Test | Purpose |
|------|---------|
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
| `TestCancelRowOverlapAddsBackOneInnerPadding` | Verifies that `cancelRowOverlap` adds back exactly one inner padding on the top edge only, leaving width and the row below unaffected. |
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
---
@@ -514,8 +608,8 @@ Tests the branded theme and the stored theme choice.
|------|---------|
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
| `TestThemeForChoice` | Verifies the GoSentry choice yields the branded primary and every other value — including the empty legacy one — yields the default theme. |
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. |
| `TestThemeForChoice` | Verifies the GoSentry choice and the empty legacy value yield the branded primary; only the explicit system choice yields Fyne's built-in theme. |
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the GoSentry label rather than a blank option. |
---
@@ -528,7 +622,20 @@ Tests main view construction with an injected `*app.Service`.
| Test | Purpose |
|------|---------|
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
| `TestMainViewBuilds` | Verifies `newMainView` assembles tabs without panic using `fyne.io/fyne/v2/test`. |
| `TestMainViewRecordStartupAddsHistoryRow` | Verifies the `recordStartup` closure `newMainView` returns appends the startup receipt to History and redraws the table, with the windowed and tray wordings `run.go` selects between. |
---
### src/ui/notify_timing_test.go
**Package:** `ui`
Tests the failure-notification timing diagnostics added in 1.0.2.
| 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`). |
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.tsv` with its header on first write and appends a row containing the job name. |
---
@@ -550,6 +657,23 @@ Tests main view construction with an injected `*app.Service`.
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
9. **Redundancy is measured, not read** — Before deleting a test as a duplicate, run both in isolation with `-coverprofile` and compare the profiles. Identical coverage alone is *not* grounds for deletion: several kept tests hit the same statements while asserting genuinely different properties (see the table below). Deletion requires identical coverage **and** assertions that are a subset of the survivor's.
---
## Look-alike tests that are kept
Every pair here has an identical coverage profile, so a redundancy pass will
flag them again. They were measured under principle 9 and kept because the
assertions differ — not because nobody looked.
| Tests | Why both stay |
|-------|---------------|
| `TestSetGlobalPausePersistsToConfigFile` / `TestSetGlobalPauseUpdatesRuntimesAndEmits` | The first asserts the flag reaches `gosentry.json`, which is what makes the pause survive a restart; the second asserts the in-memory runtimes and the emitted event. |
| `TestRunDueQueueDrainsMultipleOverlaps` / `TestRunDueQueueRerunsAfterFinish` | The first drains three queued occurrences rather than one, so it is the test that would catch a drain loop that fires only once. |
| `TestCreateStartupShortcutHandlesCyrillicPath` / `TestCreateStartupShortcutHandlesSpaces` | Non-ASCII paths and paths with spaces are different real-world failure modes for the WScript.Shell COM call. |
| `TestRunJobLogFileAllHeaders` / `TestRunJobRecordFields` / `TestRunJobWritesLogFile` | Three different subjects: the log file's headers, the returned `RunRecord`'s fields, and the log file's name and directory. The fixtures differ too — only `TestRunJobWritesLogFile` runs the `Manual` trigger. Merging them into one `RunJob` call was measured and declined: it saves ~90 ms (the three cost 0.14 s combined; the `runner` package's seconds are `TestRunJobTimesOut` and `TestRunJobZeroTimeoutMeansNoTimeout`, which wait on purpose) and would drop the `Manual` path from the header assertions. |
---
## Remaining Test Coverage Gaps
@@ -557,3 +681,19 @@ Tests main view construction with an injected `*app.Service`.
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
### Functions deliberately at 0%
A coverage run over the non-UI packages reports these as uncovered. All are
intentional; none is an oversight to be "fixed" with a test.
- The real `Clock` — a fake is injected everywhere it is used.
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Open` — process entry points, exercised by running the app.
- The autostart and desktop-icon wrappers (`app.Service.InstallDesktopIcon`, `AutostartStatus`, `ApplyAutostart`) — OS integration, driven only on a real desktop.
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
- `app.Service.Config` and `app.Service.Paths` — read only from `src/ui`, which
this run excludes, so they are covered by the suite but not by this profile.
The same applies to `storage.Store.SaveJobs`: the engine writes through
`PrepareSaveJobs`, and the one-shot wrapper is what `OpenStore` uses.
- The five `isEvent` marker methods in `app/events.go` — empty bodies that exist
only to close the `Event` interface.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

+39
View File
@@ -0,0 +1,39 @@
# Measures the latency of Fyne's Windows toast path: write a short PowerShell
# script to %TEMP% and run it via PowerShell -ExecutionPolicy Bypass, the same
# approach fyne.io/fyne/v2/app uses in app_windows.go SendNotification.
param(
[int]$Iterations = 3
)
$template = @'
$title = "GoSentry timing test"
$content = "benchmark"
$iconPath = "file:///"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
$toastXml = [xml] $template.GetXml()
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($toastXml.OuterXml)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ru.mixeme.gosentry.desktop").Show($toast);
'@
Write-Host "Fyne-style Windows toast latency ($Iterations run(s), no icon path):"
$totalMs = 0
for ($i = 1; $i -le $Iterations; $i++) {
$scriptPath = Join-Path $env:TEMP ("fyne-timing-test-$i.ps1")
Set-Content -Path $scriptPath -Value $template -Encoding UTF8
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$launch = "(Get-Content -Encoding UTF8 -Path `"$scriptPath`" -Raw) | Invoke-Expression"
& PowerShell -ExecutionPolicy Bypass -Command $launch | Out-Null
$sw.Stop()
$ms = [int]$sw.ElapsedMilliseconds
$totalMs += $ms
Write-Host (" run {0}: {1} ms" -f $i, $ms)
Remove-Item $scriptPath -ErrorAction SilentlyContinue
}
$avg = [math]::Round($totalMs / [double]$Iterations)
Write-Host (" average: {0} ms" -f $avg)
+7
View File
@@ -6,6 +6,13 @@ REM Runs go vet and go test with race detection
REM Move to repository root
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 expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH.
+6 -3
View File
@@ -4,9 +4,12 @@ import "gitea.mixdep.ru/mix/gosentry/src/domain"
// Event is something the Service did to its state that observers may want to
// react to. It is a sealed interface: the concrete types in this file are the
// only implementations (enforced by the unexported isEvent marker), so a UI
// listener can exhaustively type-switch over them and the compiler will flag a
// new event type that a switch forgot to handle.
// only implementations (enforced by the unexported isEvent marker), so an
// Event handed to an Observer is always one of the types declared here — a
// caller outside this package cannot manufacture a new one. Go's type switch
// has no exhaustiveness check, so sealing buys that guarantee, not a
// compile-time warning when a new event type is added and a listener forgets
// to handle it; the listener still has to be updated by hand.
//
// Events replace the old single onChange callback. Instead of the scheduler
// reaching into the GUI, the Service emits typed events and the UI subscribes —
-6
View File
@@ -33,12 +33,6 @@ func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
}
}
func TestEmitWithNoObserversIsNoop(t *testing.T) {
svc := newTestService(nil)
// Must not panic with an empty observer list.
svc.emit(JobChanged{})
}
// Observers may read Service state from within OnEvent without deadlocking,
// because emit is called outside the state lock.
func TestObserverCanReadServiceState(t *testing.T) {
+18 -3
View File
@@ -82,14 +82,29 @@ func DisplayInvocation(job domain.Job) string {
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
}
// DisplayStats returns a one-line execution-time summary for a job runtime.
// Returns "No runs recorded" when no runs have been counted yet.
// DisplayStats returns a one-line execution-time summary for a job runtime,
// with the queued-run depth appended whenever the "queue" overlap policy has
// deferred runs waiting (see maxPendingRuns). Returns "No runs recorded" when
// no runs have been counted yet, still showing the queue depth if one exists.
func DisplayStats(rt *domain.JobRuntime) string {
if rt == nil || rt.RunCount == 0 {
if rt != nil && rt.PendingRuns > 0 {
return "No runs recorded" + pendingRunsSuffix(rt.PendingRuns)
}
return "No runs recorded"
}
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS)
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) +
pendingRunsSuffix(rt.PendingRuns)
}
// pendingRunsSuffix formats the queued-run depth for DisplayStats, empty when
// nothing is queued.
func pendingRunsSuffix(pending int) string {
if pending <= 0 {
return ""
}
return fmt.Sprintf(", %d queued", pending)
}
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
+11
View File
@@ -126,6 +126,17 @@ func TestDisplayStats(t *testing.T) {
if got := DisplayStats(rtNoFail); got != wantNoFail {
t.Errorf("DisplayStats no-fail = %q, want %q", got, wantNoFail)
}
// A "queue" overlap backlog is appended to whichever form applies, so it stays
// visible even before the first run has completed.
if got, want := DisplayStats(&domain.JobRuntime{PendingRuns: 2}), "No runs recorded, 2 queued"; got != want {
t.Errorf("DisplayStats pending, no runs = %q, want %q", got, want)
}
rtPending := &domain.JobRuntime{RunCount: 5, FailCount: 2, LastDurationMS: 450, AvgDurationMS: 380, MaxDurationMS: 520, PendingRuns: 3}
wantPending := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms, 3 queued"
if got := DisplayStats(rtPending); got != wantPending {
t.Errorf("DisplayStats pending = %q, want %q", got, wantPending)
}
}
func TestEventLine(t *testing.T) {
+82 -43
View File
@@ -43,15 +43,23 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
s.parseScheduleLocked(&job)
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
if err != nil {
s.jobs = s.jobs[:len(s.jobs)-1]
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); err != nil {
// The write is atomic, so a failure left the file holding the previous
// list: take the job back out so memory matches what is on disk. Another
// operation may have run in between, so it is removed by ID rather than by
// truncating the slice.
s.mu.Lock()
if index := s.indexByIDLocked(job.ID); index >= 0 {
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
}
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return job, nil
@@ -87,10 +95,10 @@ func (s *Service) UpdateJob(job domain.Job) error {
s.refreshNextRunLocked(existing, runtime)
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -113,10 +121,10 @@ func (s *Service) DeleteJob(id int) error {
delete(s.runtimes, id)
delete(s.schedules, id)
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -148,13 +156,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
runtime.NextDue = time.Time{}
// A disabled job's own occurrences stop firing, so a "queue" backlog it was
// carrying no longer corresponds to anything: clear it rather than replaying
// stale deferred runs if the job is re-enabled later.
runtime.PendingRuns = 0
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -175,15 +187,19 @@ func (s *Service) SetGlobalPause(paused bool) error {
for index := range s.jobs {
job := &s.jobs[index]
runtime := s.runtimeForLocked(job)
if paused {
// A "queue" backlog counts occurrences missed *while paused is off*; once
// paused, none of those correspond to anything the user would expect
// replayed on resume, so drop it rather than letting a stale counter fire
// a deferred run for an occurrence from before the pause.
runtime.PendingRuns = 0
}
s.refreshNextRunFromLocked(job, runtime, now)
}
err := s.store.SaveConfig()
if err == nil {
err = s.store.SaveJobs(s.jobs)
}
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
state, detail := "Resumed", "All job execution resumed"
@@ -211,9 +227,9 @@ func (s *Service) SetJobListView(view domain.JobListView) error {
return nil
}
s.store.Config.JobListView = view
err := s.store.SaveConfig()
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
s.mu.Unlock()
return err
return save()
}
// ShouldNotifyOnFailure reports whether the user has enabled desktop
@@ -243,54 +259,75 @@ func (s *Service) UpdateSettings(config domain.Config) error {
config.JobsFile = strings.TrimSpace(config.JobsFile)
s.mu.Lock()
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
// AppDir is fixed for the process and only UpdateSettings itself — a UI
// action — can move JobsPath, so this snapshot stays valid across the reads
// below.
appDir := s.store.Paths.AppDir
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
switching := jobsPath != s.store.Paths.JobsPath
if switching && s.anyRunningLocked() {
s.mu.Unlock()
running := s.anyRunningLocked()
s.mu.Unlock()
if switching && running {
return errors.New("cannot change the jobs file while a job is running")
}
// Read the new file before anything is written, so a file that cannot be
// parsed leaves both the config and the current jobs untouched.
// Read the new file, and reconstruct its jobs' statistics from the logs the
// new config points at, before anything is written and while no lock is held:
// both are file I/O, and SeedStats opens every log in the directory. A file
// that cannot be parsed leaves both the config and the current jobs untouched.
var adopted []domain.Job
var seeds map[int]runner.SeededStats
if switching {
jobs, found, err := storage.LoadJobsFile(jobsPath)
if err != nil {
s.mu.Unlock()
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
}
if found {
adopted = jobs
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
}
}
s.store.Config = config
if err := s.store.SaveConfig(); err != nil {
s.mu.Lock()
// The guard above was evaluated before the reads, off the lock, so re-check
// it: a scheduled run may have started in the meantime, and adoption drops
// every runtime.
if switching && s.anyRunningLocked() {
s.mu.Unlock()
return err
return errors.New("cannot change the jobs file while a job is running")
}
s.store.Config = config
saveConfig := s.store.PrepareSaveConfig()
if adopted != nil {
s.adoptJobsLocked(adopted)
s.applySeededStatsLocked(seeds)
}
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
// jobs are written back too, which persists the IDs and defaults that
// normalization filled in, exactly as loading them at startup would.
if err := s.store.SaveJobs(s.jobs); err != nil {
s.mu.Unlock()
return err
}
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
// write targets the (possibly new) jobs file and cleanup targets the new logs
// dir. Adopted jobs are written back too, which persists the IDs and defaults
// that normalization filled in, exactly as loading them at startup would. The
// jobs write is skipped when the config write fails, because both writes run
// in the order prepared and stop at the first error.
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
loaded := len(s.jobs)
logsDir := s.store.Paths.LogsDir
maxFiles := s.store.Config.MaxLogFiles
maxAge := s.store.Config.MaxLogAgeDays
s.mu.Unlock()
saveErr := save()
if adopted != nil {
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
// History which file those jobs came from, since nothing was asked.
// History which file those jobs came from, since nothing was asked. Both
// are emitted even when the write failed: the adopted jobs are already the
// in-memory list, and a job list the user cannot see would be worse than
// the error they are about to be shown.
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
s.emit(JobChanged{})
}
if saveErr != nil {
return saveErr
}
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
}
@@ -466,11 +503,13 @@ func validateConfig(config domain.Config) error {
if strings.TrimSpace(config.LogsDir) == "" {
return errors.New("logs directory is required")
}
if config.MaxLogFiles <= 0 {
return errors.New("max log files must be a positive number")
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
if config.MaxLogFiles < 0 {
return errors.New("max log files must be zero (unlimited) or a positive number")
}
if config.MaxLogAgeDays <= 0 {
return errors.New("max log age days must be a positive number")
if config.MaxLogAgeDays < 0 {
return errors.New("max log age days must be zero (unlimited) or a positive number")
}
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
return errors.New("execution mode must be 'parallel' or 'sequential'")
@@ -481,10 +520,10 @@ func validateConfig(config domain.Config) error {
if config.DefaultTimeoutSeconds < 0 {
return errors.New("default timeout must not be negative (0 means no timeout)")
}
// Empty Theme is accepted and normalized to the default on load, so older
// configs (and hand-built ones) stay valid without an explicit theme.
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
return errors.New("theme must be 'default' or 'gosentry'")
// Empty Theme is accepted and normalized to the branded theme on load, so
// older configs (and hand-built ones) stay valid without an explicit theme.
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
return errors.New("theme must be 'system' or 'gosentry'")
}
return nil
}
+136 -8
View File
@@ -3,8 +3,10 @@ package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
@@ -217,6 +219,23 @@ func TestSetEnabledToggles(t *testing.T) {
}
}
// TestSetEnabledClearsPendingRuns verifies that disabling a job drops any
// "queue" overlap backlog it was carrying, so re-enabling it later does not
// replay a deferred run for an occurrence that fired before the disable.
func TestSetEnabledClearsPendingRuns(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
svc.runtimes[1].PendingRuns = 2
svc.mu.Unlock()
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.PendingRuns != 0 {
t.Errorf("PendingRuns after disable = %d, want 0", rt.PendingRuns)
}
}
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
svc := newTempService(t, []domain.Job{
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
@@ -503,9 +522,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = 0
bad.MaxLogFiles = -1
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for non-positive max log files")
t.Error("expected validation error for negative max log files")
}
good := svc.store.Config
@@ -514,8 +533,21 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.Store().Config)
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.store.Config)
}
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
// invalid value, so it must be accepted and persisted rather than rejected
// or silently backfilled.
unlimited := svc.store.Config
unlimited.MaxLogFiles = 0
unlimited.MaxLogAgeDays = 0
if err := svc.UpdateSettings(unlimited); err != nil {
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
}
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
}
}
@@ -530,8 +562,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
}
for _, tc := range tests {
@@ -693,12 +725,12 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
if err := svc.UpdateSettings(config); err == nil {
t.Error("expected the jobs-file switch to be refused while a job is running")
}
if svc.Store().Config.JobsFile == config.JobsFile {
if svc.store.Config.JobsFile == config.JobsFile {
t.Error("the refused switch must not have been persisted")
}
// A setting that does not touch the jobs file still saves during a run.
unrelated := svc.Store().Config
unrelated := svc.store.Config
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
if err := svc.UpdateSettings(unrelated); err != nil {
t.Errorf("unrelated setting should still save during a run: %v", err)
@@ -708,6 +740,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
waitRecord(t, done)
}
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
// files the new configuration points at. That scan opens every log in the
// directory, so UpdateSettings runs it before taking the state lock; this pins
// that its result still reaches the runtime map.
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
logsDir := svc.store.Paths.LogsDir
if err := os.MkdirAll(logsDir, 0o755); err != nil {
t.Fatal(err)
}
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
t.Fatal(err)
}
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
runtime := svc.Runtime(7)
if runtime == nil {
t.Fatal("the adopted job has no runtime")
}
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
}
}
// Job saves run after mu is released, so one operation can be writing while
// another mutates state. deferSaveLocked takes its own lock while mu is still
// held, which is what keeps writes in mutation order: whatever changed the list
// last also wrote it last, so the file ends up matching memory instead of
// holding an older snapshot.
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
svc := newTempService(t, nil)
const workers = 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Errorf("CreateJob %d: %v", i, err)
return
}
if err := svc.SetEnabled(job.ID, false); err != nil {
t.Errorf("SetEnabled %d: %v", job.ID, err)
}
}(i)
}
wg.Wait()
memory := svc.Jobs()
if len(memory) != workers {
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
}
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
if err != nil || !found {
t.Fatalf("read jobs file: found=%v err=%v", found, err)
}
if len(saved) != len(memory) {
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
}
onDisk := make(map[int]domain.Job, len(saved))
for _, job := range saved {
onDisk[job.ID] = job
}
for _, job := range memory {
got, ok := onDisk[job.ID]
if !ok {
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
continue
}
if got.Name != job.Name || got.Enabled != job.Enabled {
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
}
}
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
+18 -9
View File
@@ -1,39 +1,48 @@
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
// current StartOnLogin setting in the stored config.
// current StartOnLogin and KeepRunningInTray settings in the stored config.
func (s *Service) AutostartStatus() (ok bool, message string) {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
manager := s.manager
s.mu.Unlock()
if manager == nil {
return false, "autostart not available"
}
return manager.Status(enabled, execPath)
return manager.Status(enabled, startInTray, execPath)
}
// ApplyAutostart writes or removes the platform autostart entry to match the
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
// current StartOnLogin and KeepRunningInTray settings. Call after UpdateSettings.
func (s *Service) ApplyAutostart() error {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
iconPath := s.store.Paths.DesktopIcon
manager := s.manager
@@ -41,5 +50,5 @@ func (s *Service) ApplyAutostart() error {
if manager == nil {
return nil
}
return manager.Set(enabled, execPath, iconPath)
return manager.Set(enabled, startInTray, execPath, iconPath)
}
+31 -43
View File
@@ -11,6 +11,13 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/runner"
)
// maxPendingRuns bounds how many missed occurrences the "queue" overlap policy
// will defer for one job. Without a ceiling a job whose runs take longer than
// its interval would queue one more occurrence on every tick forever, so once
// the cap is reached further overlaps are dropped exactly as the "skip" policy
// would drop them, until the backlog drains below the cap again.
const maxPendingRuns = 10
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
// automatic runs (see RunDue), so a manual "Run now" is allowed even while
// paused — it is the user's explicit, one-off action. It will not start a job
@@ -36,14 +43,12 @@ func (s *Service) RunNow(id int) error {
s.mu.Unlock()
return errors.New("another job is already running (sequential mode)")
}
err := s.startRunLocked(job, runtime, "Manual", time.Now())
s.startRunLocked(job, runtime, "Manual", time.Now())
s.mu.Unlock()
if err == nil {
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
}
return err
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
return nil
}
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
@@ -63,7 +68,6 @@ func (s *Service) RunNow(id int) error {
func (s *Service) RunDue(now time.Time) {
s.mu.Lock()
var started []int
var startErr error
if !s.paused {
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
running := s.anyRunningLocked()
@@ -77,7 +81,7 @@ func (s *Service) RunDue(now time.Time) {
// The job came due again while its own run is still in flight.
// Apply the effective overlap policy and step past this
// occurrence.
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue && runtime.PendingRuns < maxPendingRuns {
runtime.PendingRuns++
}
s.advanceNextDueLocked(job, runtime, now)
@@ -88,19 +92,13 @@ func (s *Service) RunDue(now time.Time) {
// tick once the in-flight run has finished.
continue
}
if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
startErr = err
continue
}
s.startRunLocked(job, runtime, "Schedule", now)
started = append(started, job.ID)
running = true
}
}
s.mu.Unlock()
if startErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
}
for _, id := range started {
s.emit(JobChanged{JobID: id})
}
@@ -116,29 +114,19 @@ type runEnv struct {
}
// startRunLocked transitions a job to "Running", advances its NextDue to the next
// scheduled occurrence, persists that, and launches the run on a background
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
// firing during a long run and apply the overlap policy. The caller must hold mu.
// now is the reference time for next-due advancement and the running placeholder.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
// scheduled occurrence, and launches the run on a background goroutine. Neither
// step touches a durable field — both live on JobRuntime, which is never
// persisted — so there is nothing to save here. Advancing (rather than zeroing)
// NextDue keeps the schedule marching while the run is in flight, which is what
// lets RunDue notice a fresh occurrence firing during a long run and apply the
// overlap policy. The caller must hold mu. now is the reference time for
// next-due advancement and the running placeholder.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) {
jobCopy := *job
prevState := runtime.LastState
prevNextRun := runtime.NextRun
prevOutput := runtime.Output
prevNextDue := runtime.NextDue
runtime.LastState = "Running"
runtime.NextRun = "Running"
runtime.Output = runningOutput(jobCopy, trigger, now)
s.advanceNextDueLocked(job, runtime, now)
if err := s.store.SaveJobs(s.jobs); err != nil {
runtime.LastState = prevState
runtime.NextRun = prevNextRun
runtime.Output = prevOutput
runtime.NextDue = prevNextDue
return err
}
env := runEnv{
logsDir: s.store.Paths.LogsDir,
maxFiles: s.store.Config.MaxLogFiles,
@@ -148,7 +136,6 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
// from under the goroutine after we release mu.
go s.executeRun(s.ctx, jobCopy, trigger, env)
return nil
}
// executeRun runs the job off the lock, then records the result back through the
@@ -160,7 +147,6 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
s.mu.Lock()
var cleanupErr, saveErr error
var rerunStarted bool
if current := s.findByIDLocked(jobCopy.ID); current != nil {
runtime := s.runtimeForLocked(current)
@@ -174,25 +160,26 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
runtime.PendingRuns--
// A scheduled occurrence fired while this run was active under the
// "queue" policy; start one deferred run now.
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
rerunStarted = saveErr == nil
s.startRunLocked(current, runtime, "Schedule", time.Now())
rerunStarted = true
} else {
s.refreshNextRunLocked(current, runtime)
saveErr = s.store.SaveJobs(s.jobs)
}
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
}
s.mu.Unlock()
// Cleanup is a directory scan plus up to MaxLogFiles unlinks. It needs only
// the values already snapshotted into runEnv, so it runs after mu is released
// rather than making every UI refresh wait behind it. It runs even when the
// job is gone, because the run still wrote a log file that retention covers.
cleanupErr := runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
if logErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
}
if cleanupErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
}
if saveErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
}
s.emit(RunRecorded{Record: record})
if !rerunStarted {
s.emit(JobChanged{JobID: jobCopy.ID})
@@ -266,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
+60 -78
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"os"
"sync/atomic"
"testing"
"time"
@@ -104,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) {
@@ -255,6 +263,10 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
// Empty per-job OverlapPolicy inherits the global queue policy.
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
@@ -355,6 +367,42 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
}
}
// TestRunDueQueueCapsPendingRuns verifies that a job whose runs never keep up
// with its schedule stops accumulating PendingRuns at maxPendingRuns instead of
// growing without bound.
func TestRunDueQueueCapsPendingRuns(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
t.Cleanup(func() {
close(release)
waitRecord(t, done)
})
primeDue(t, svc, 1)
svc.RunDue(time.Now())
// Far more due ticks than the cap while the first run stays in flight.
for range maxPendingRuns + 5 {
primeDue(t, svc, 1)
svc.RunDue(time.Now())
}
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != maxPendingRuns {
t.Fatalf("PendingRuns = %d, want capped at %d", pending, maxPendingRuns)
}
}
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
// "queue" policy queues a re-run even though the global default is "skip": the
// effective policy is resolved per job, so the job-level value wins.
@@ -451,51 +499,6 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
}
}
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
// the job queues a re-run.
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Fatalf("empty per-job policy must inherit global queue, PendingRuns = %d", pending)
}
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
}
waitRecord(t, done)
}
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
// run is refused while another job is running, and allowed once nothing is.
func TestRunNowSequentialGuard(t *testing.T) {
@@ -534,37 +537,9 @@ func TestRunNowSequentialGuard(t *testing.T) {
waitRecord(t, done)
}
// TestStartRunLockedRollbackOnSaveFailure is a regression test for CODE_REVIEW
// finding #2: a run must not start when persisting the Running state fails.
func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true}})
if err := svc.store.SaveJobs(svc.jobs); err != nil {
t.Fatalf("seed jobs.json: %v", err)
}
if err := os.Chmod(svc.store.Paths.JobsPath, 0o444); err != nil {
t.Fatalf("chmod jobs.json: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
var started int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&started, 1)
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
}
if err := svc.RunNow(1); err == nil {
t.Fatal("expected RunNow to fail when jobs.json is not writable")
}
if atomic.LoadInt32(&started) != 0 {
t.Error("run goroutine must not start when SaveJobs fails")
}
if rt := svc.Runtime(1); rt == nil || rt.LastState == "Running" {
t.Errorf("runtime should roll back from Running, got %+v", rt)
}
}
// TestRunDueQueueDrainSkippedWhenPaused verifies that queued overlap runs are not
// drained while the scheduler is globally paused.
// drained while the scheduler is globally paused, and that pausing clears the
// backlog rather than leaving it to fire a stale deferred run on resume.
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
@@ -602,6 +577,13 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
t.Fatalf("SetGlobalPause: %v", err)
}
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("pausing must clear a queued backlog, PendingRuns = %d, want 0", pending)
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
@@ -609,8 +591,8 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 1", pending)
if pending != 0 {
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 0", pending)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
+70 -13
View File
@@ -26,7 +26,10 @@ import (
// it; unexported helpers ending in "Locked" assume the caller already holds it.
// The Service must never call back into the UI (or any code that might re-enter
// the Service) while holding mu — in particular emit() is always called after
// mu is released.
// mu is released. Blocking file I/O follows the same rule: mu is the lock the
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
// log-directory scan, or a pass over every log header must not happen inside it
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
type Service struct {
mu sync.Mutex
store *storage.Store
@@ -56,6 +59,13 @@ type Service struct {
// do not exercise autostart; Open() wires it via autostart.New().
manager autostart.Manager
// saveMu serializes the store writes that operations prepare under mu and run
// after releasing it. It is taken while mu is still held and released once the
// write is done, so writes reach the file in the same order their snapshots
// were taken and an older snapshot can never land on top of a newer one.
// Nothing may take mu while holding saveMu.
saveMu sync.Mutex
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
@@ -63,6 +73,26 @@ type Service struct {
observers []Observer
}
// deferSaveLocked prepares the store writes for the caller to run after mu is
// released, and takes saveMu now so a later operation's write cannot overtake
// this one. The caller must hold mu, must unlock it before calling the returned
// function, and must call that function exactly once. Keeping the marshal, the
// fsync, and the rename out of the critical section is what stops a settings
// change or a job edit from blocking a scheduler tick or a finishing run. The
// writes run in the order given and stop at the first error.
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
s.saveMu.Lock()
return func() error {
defer s.saveMu.Unlock()
for _, write := range writes {
if err := write(); err != nil {
return err
}
}
return nil
}
}
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
@@ -77,18 +107,19 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
// No lock is needed here: construction is single-threaded, before Start
// launches the timing loop.
s.adoptJobsLocked(jobs)
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
return s
}
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
// derived from it: the runtime map, the parsed-schedule cache, each job's first
// next-run — so the Service is ready to schedule the moment it exists, mirroring
// the old scheduler's reset-on-construction — and the statistics seeded from
// existing log files, so the details panel shows accumulated run history
// immediately rather than only runs since this process started.
// derived from it: the runtime map, the parsed-schedule cache, and each job's
// first next-run — so the Service is ready to schedule the moment it exists,
// mirroring the old scheduler's reset-on-construction.
//
// It backs both construction and a Settings change that points at a different
// jobs file. The caller must hold mu.
// jobs file. Statistics seeded from existing log files are applied separately by
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
// must hold mu.
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.jobs = jobs
s.runtimes = domain.NewRuntimes(jobs)
@@ -100,7 +131,16 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.parseScheduleLocked(job)
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
}
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
}
// applySeededStatsLocked folds statistics reconstructed from existing log files
// into the runtime map, so the details panel shows accumulated run history
// immediately rather than only runs since this process started. It is separate
// from adoptJobsLocked because producing the seeds opens every log file in the
// directory, which must not happen under mu: callers compute the map first and
// apply it here. The caller must hold mu.
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
for id, seed := range seeds {
runtime := s.runtimes[id]
if runtime == nil {
continue
@@ -111,6 +151,7 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
runtime.AvgDurationMS = seed.AvgDurationMS
runtime.MaxDurationMS = seed.MaxDurationMS
runtime.TimedRunCount = seed.TimedRunCount
runtime.DurationSumMS = seed.DurationSumMS
}
}
@@ -164,11 +205,27 @@ func Open() (*Service, error) {
return svc, nil
}
// Store returns the underlying store. It is exposed so callers that still need
// resolved paths and config (the GUI, during the transition) can reach them;
// later phases narrow this surface.
func (s *Service) Store() *storage.Store {
return s.store
// Config returns a copy of the current application configuration, safe to
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
// are the only writers and all mutate store.Config under mu; copying under the
// same lock is what keeps a UI read from racing them, instead of holding onto
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
// Service state through typed events and accessors, never shared mutable
// state).
func (s *Service) Config() domain.Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Config
}
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
// so this copies under the same lock as Config for the same reason.
func (s *Service) Paths() storage.Paths {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Paths
}
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
-8
View File
@@ -47,11 +47,3 @@ func TestJobsReturnsCopy(t *testing.T) {
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
}
}
func TestStoreReturnsWiredStore(t *testing.T) {
store := &storage.Store{}
svc := NewService(store, nil)
if svc.Store() != store {
t.Error("Store() did not return the wired store")
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build.
var Version = "1.0.0"
var Version = "1.0.3"
+22 -5
View File
@@ -5,6 +5,23 @@ package domain
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// AutostartArguments returns the command-line suffix written to a platform
// autostart entry when KeepRunningInTray is enabled. An empty string means the
// app should open its window normally after sign-in.
func AutostartArguments(keepInTray bool) string {
if keepInTray {
return StartInTrayArgument
}
return ""
}
// ResolveStartHidden reports whether an autostart launch should skip showing
// the main window. The CLI flag is ignored when KeepRunningInTray is off so a
// stale shortcut cannot hide the app with no tray icon to restore it.
func ResolveStartHidden(cliStartInTray, keepInTray bool) bool {
return cliStartInTray && keepInTray
}
// ExecutionMode controls whether due jobs run concurrently or one at a time.
type ExecutionMode string
@@ -21,8 +38,8 @@ const (
type Theme string
const (
// ThemeDefault keeps Fyne's built-in theme — the original look.
ThemeDefault Theme = "default"
// ThemeSystem keeps Fyne's built-in theme, following the platform look.
ThemeSystem Theme = "system"
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
// and app icon.
ThemeGoSentry Theme = "gosentry"
@@ -87,8 +104,8 @@ type Config struct {
// omitempty would hide a deliberate choice from the hand-editable config.
DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
Paused bool `json:"paused,omitempty"`
// Theme selects the visual appearance. Empty is treated as ThemeDefault so
// configs written before this field existed keep the original look.
// Theme selects the visual appearance. Empty is treated as ThemeGoSentry so
// configs written before this field existed pick up the branded look.
Theme Theme `json:"theme,omitempty"`
// JobListView selects the Jobs list density. Empty is treated as
// JobListViewDetailed so configs written before this field existed keep the
@@ -110,7 +127,7 @@ func DefaultConfig() Config {
NotifyOnFailure: true,
ExecutionMode: ExecutionModeParallel,
OverlapPolicy: OverlapPolicySkip,
Theme: ThemeDefault,
Theme: ThemeGoSentry,
JobListView: JobListViewDetailed,
DefaultTimeoutSeconds: 0,
}
+25 -6
View File
@@ -2,6 +2,31 @@ package domain
import "testing"
func TestAutostartArguments(t *testing.T) {
if got := AutostartArguments(true); got != StartInTrayArgument {
t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument)
}
if got := AutostartArguments(false); got != "" {
t.Errorf("AutostartArguments(false) = %q, want empty", got)
}
}
func TestResolveStartHidden(t *testing.T) {
cases := []struct {
cli, keep, want bool
}{
{true, true, true},
{true, false, false},
{false, true, false},
{false, false, false},
}
for _, tc := range cases {
if got := ResolveStartHidden(tc.cli, tc.keep); got != tc.want {
t.Errorf("ResolveStartHidden(%v, %v) = %v, want %v", tc.cli, tc.keep, got, tc.want)
}
}
}
// TestJobListViewIsCompact pins the normalization rule: only the exact
// "compact" value selects the one-line rows, so empty and unrecognised values
// (including configs written before the field existed) keep the detailed look.
@@ -22,9 +47,3 @@ func TestJobListViewIsCompact(t *testing.T) {
}
}
}
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
}
}
+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
+5 -3
View File
@@ -3,7 +3,9 @@ package autostart
// Manager controls platform autostart for the application.
type Manager interface {
// Set writes or removes the platform autostart entry to match enabled.
Set(enabled bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled.
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
// When enabled, startInTray selects whether the entry passes --start-in-tray.
Set(enabled, startInTray bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled
// and startInTray.
Status(expectedEnabled, startInTray bool, executablePath string) (ok bool, message string)
}
+20 -10
View File
@@ -17,17 +17,17 @@ type linuxManager struct{}
// New returns the Linux autostart Manager.
func New() Manager { return linuxManager{} }
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
@@ -36,15 +36,19 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
execLine := quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
execLine += " " + args
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
Exec=%s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
`, execLine, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
@@ -54,7 +58,7 @@ X-GNOME-Autostart-enabled=true
return nil
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
@@ -70,9 +74,15 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
expectedExec += " " + args
}
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
if startInTray {
return false, "Autostart desktop entry does not start in tray"
}
return false, "Autostart desktop entry starts in tray while setting is off"
}
return true, "Autostart is configured"
}
+27 -1
View File
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
if err := setAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
@@ -33,3 +33,29 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
}
}
func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := setAutostart(true, false, executablePath, ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry should not include tray flag: %s", data)
}
if strings.Contains(string(data), domain.StartInTrayArgument) {
t.Fatalf("desktop entry must not pass --start-in-tray: %s", data)
}
}
+6 -6
View File
@@ -9,22 +9,22 @@ type otherManager struct{}
// New returns the stub autostart Manager for unsupported platforms.
func New() Manager { return otherManager{} }
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
+16 -12
View File
@@ -16,18 +16,18 @@ type windowsManager struct{}
// New returns the Windows autostart Manager.
func New() Manager { return windowsManager{} }
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartName = "GoSentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
// Windows autostart used to write HKCU\Run values, but that approach became
// brittle once paths with spaces and the "--start-in-tray" argument entered
// the picture. A Startup-folder shortcut stores target path and arguments as
@@ -39,12 +39,12 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
}
if enabled {
return createStartupShortcut(shortcutPath, executablePath, iconPath)
return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray))
}
return removeIfExists(shortcutPath)
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
shortcutPath, err := startupShortcutPath()
if err != nil {
return false, "Startup folder cannot be resolved"
@@ -74,8 +74,12 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
expectedArgs := domain.AutostartArguments(startInTray)
if strings.TrimSpace(arguments) != expectedArgs {
if startInTray {
return false, "Autostart shortcut does not start in tray"
}
return false, "Autostart shortcut starts in tray while setting is off"
}
return true, "Autostart is configured"
}
@@ -88,7 +92,7 @@ func startupShortcutPath() (string, error) {
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
}
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string, arguments string) error {
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
return err
}
@@ -106,7 +110,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_ARGUMENTS="+arguments,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
@@ -122,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
// os.Executable, causing AutostartStatus to report "shortcut points to
// os.Executable, causing autostartStatus to report "shortcut points to
// another executable" for any install path that contains non-ASCII chars.
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
@@ -12,14 +12,8 @@ import (
)
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
t.Fatal("expected paths to match")
}
}
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
t.Fatal("expected paths with spaces to match")
t.Fatal("expected paths to match")
}
}
@@ -88,7 +82,7 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
@@ -104,6 +98,51 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
}
}
func TestCreateStartupShortcutWithoutTrayFlag(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
_, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if arguments != "" {
t.Fatalf("shortcut arguments mismatch: got %q want empty", arguments)
}
}
func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("APPDATA", tempDir)
shortcutPath, err := startupShortcutPath()
if err != nil {
t.Fatalf("startupShortcutPath: %v", err)
}
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
ok, message := autostartStatus(true, false, targetPath)
if ok {
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
}
if message != "Autostart shortcut starts in tray while setting is off" {
t.Fatalf("unexpected message: %q", message)
}
}
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
@@ -115,7 +154,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
+5
View File
@@ -9,6 +9,11 @@ import (
"time"
)
// CleanupLogs enforces the count and age retention policies on the .log files
// in logsDir. maxFiles <= 0 disables the count policy and maxAgeDays <= 0
// disables the age policy, independently — "keep everything" is a value the
// user can choose in Settings, not just an internal default (STANDARDS
// §Intentional behavior).
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
-16
View File
@@ -50,22 +50,6 @@ func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
}
}
func TestCleanupLogsKeepsFilesWithinAgeLimit(t *testing.T) {
dir := t.TempDir()
for i := 1; i <= 3; i++ {
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
setModTime(t, path, time.Duration(i)*24*time.Hour)
}
if err := CleanupLogs(dir, 100, 30); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Errorf("expected 3 files kept within age limit, got %d", len(entries))
}
}
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
// than maxFiles log files exist the oldest (by modification time) are removed.
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
-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) }
+37 -10
View File
@@ -6,6 +6,7 @@ import (
"strings"
"syscall"
"unicode"
"unicode/utf8"
)
func shellCommand(ctx context.Context, command string) *exec.Cmd {
@@ -32,19 +33,45 @@ func quoteLeadingWindowsProgramPath(command string) string {
}
lower := strings.ToLower(trimmed)
pathEnd := -1
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
index := strings.Index(lower, extension)
if index < 0 {
continue
end := earliestBoundedExtensionEnd(lower, extension)
if end >= 0 && (pathEnd < 0 || end < pathEnd) {
pathEnd = end
}
pathEnd := index + len(extension)
programPath := trimmed[:pathEnd]
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
return command
}
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
}
return command
if pathEnd < 0 {
return command
}
programPath := trimmed[:pathEnd]
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
return command
}
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
}
// earliestBoundedExtensionEnd returns the offset just past the first
// occurrence of extension in s that ends at a token boundary (end of string
// or whitespace), or -1 if none does. Scanning left to right and rejecting
// unbounded matches keeps a trailing "...\App.exe" inside an argument, such
// as "run.bat C:\tool.exe", from being mistaken for the program path.
func earliestBoundedExtensionEnd(s, extension string) int {
offset := 0
for {
index := strings.Index(s[offset:], extension)
if index < 0 {
return -1
}
end := offset + index + len(extension)
if end == len(s) {
return end
}
r, _ := utf8.DecodeRuneInString(s[end:])
if unicode.IsSpace(r) {
return end
}
offset += index + 1
}
}
func startsWithWindowsRootedPath(command string) bool {
+61 -3
View File
@@ -23,15 +23,73 @@ 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)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
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)
}
return path, nil
}
// writeFileAtomic writes data to a temp file in dir, then renames it over
// path. Rename is atomic within a volume on both supported platforms, so a
// crash or a killed process mid-write can never leave path holding a
// truncated log file the way a direct os.WriteFile could.
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpPath := tmp.Name()
success := false
defer func() {
if !success {
os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, perm); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
success = true
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)
}
}
+10 -2
View File
@@ -36,7 +36,15 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
var detail string
var durationMS int64
if job.StartOnly {
invocation := jobInvocation(ctx, *job)
// A StartOnly process is deliberately never waited for, so it must not be
// tied to any cancelable context: exec.CommandContext leaves a watcher
// goroutine alive until Wait returns or the context is done, and since
// StartOnly never calls Wait that goroutine would live for the rest of the
// process — one per run — and then try to kill a process whose handle
// startJobOnly has already released. context.Background() has a nil Done
// channel, so os/exec starts no watcher at all and the started process is
// left to outlive GoSentry, which is the point of the option.
invocation := jobInvocation(context.Background(), *job)
// StartOnly jobs don't wait for process exit, so the duration measures
// launch latency (time to spawn the process) rather than run time.
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
@@ -108,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()
}
+54 -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)
}
}
}
@@ -401,6 +401,58 @@ func TestRunJobZeroTimeoutMeansNoTimeout(t *testing.T) {
}
}
// A StartOnly run must not leave a watcher goroutine behind. exec.CommandContext
// keeps one alive until Wait returns or the context is done, and StartOnly never
// waits, so binding it to the caller's cancelable context would leak one
// goroutine per run for the lifetime of the app — and then, on shutdown, kill a
// process whose handle startJobOnly has already released.
func TestRunJobStartOnlyLeavesNoContextWatcher(t *testing.T) {
command := "sh"
arguments := "-c\nexit 0"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 0"
}
job := domain.Job{
ID: 53,
Name: "Start Only Goroutines",
Command: command,
Arguments: arguments,
StartOnly: true,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const runs = 5
before := settledGoroutines()
for i := 0; i < runs; i++ {
if _, err := RunJob(ctx, &job, "Manual", t.TempDir(), 30*time.Second); err != nil {
t.Fatal(err)
}
}
// Counted before cancel on purpose: a watcher would still be parked on
// ctx.Done() at this point, and cancelling first would release it.
if leaked := settledGoroutines() - before; leaked > 1 {
t.Errorf("%d goroutines left after %d StartOnly runs, want none tied to the run context", leaked, runs)
}
}
// settledGoroutines returns the goroutine count once it has stopped falling, so
// a goroutine that is still on its way out is not mistaken for a leak.
func settledGoroutines() int {
lowest := runtime.NumGoroutine()
for stable, i := 0, 0; stable < 3 && i < 100; i++ {
time.Sleep(10 * time.Millisecond)
if count := runtime.NumGoroutine(); count < lowest {
lowest, stable = count, 0
continue
}
stable++
}
return lowest
}
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
+41
View File
@@ -51,3 +51,44 @@ func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
t.Fatalf("expected command line %q, got %q", want, got)
}
}
// TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension pins the
// fix for the quoting bug found in the whole-project review: the program
// path must end at the *earliest* extension match that sits at a token
// boundary, not the first extension in the .exe/.cmd/.bat/.com list order,
// and not a substring match inside another word.
func TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension(t *testing.T) {
cases := []struct {
name string
command string
want string
}{
{
name: "bat with unquoted argument",
command: `C:\My Tools\run.bat D:\in.txt`,
want: `"C:\My Tools\run.bat" D:\in.txt`,
},
{
name: "bat program with exe argument",
command: `C:\My Tools\run.bat C:\Windows\System32\notepad.exe`,
want: `"C:\My Tools\run.bat" C:\Windows\System32\notepad.exe`,
},
{
name: "cmd program with exe argument",
command: `C:\Program Files\App\deploy.cmd D:\stage\setup.exe`,
want: `"C:\Program Files\App\deploy.cmd" D:\stage\setup.exe`,
},
{
name: "exe substring inside directory name",
command: `C:\dir.exexample\My Tool\run.bat`,
want: `"C:\dir.exexample\My Tool\run.bat"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := quoteLeadingWindowsProgramPath(tc.command); got != tc.want {
t.Fatalf("quoteLeadingWindowsProgramPath(%q) = %q, want %q", tc.command, got, tc.want)
}
})
}
}
+56 -55
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
@@ -45,8 +49,8 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
return result
}
byID := make(map[int][]string)
byName := make(map[string][]string)
byID := make(map[int][]logSummary)
byName := make(map[string][]logSummary)
for _, entry := range entries {
if entry.IsDir() {
continue
@@ -55,9 +59,10 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if !strings.HasSuffix(strings.ToLower(name), ".log") {
continue
}
path := filepath.Join(logsDir, name)
if jobID, ok := readLogJobID(path); ok {
byID[jobID] = append(byID[jobID], name)
summary := readLogSummary(filepath.Join(logsDir, name))
summary.name = name
if summary.hasJobID {
byID[summary.jobID] = append(byID[summary.jobID], summary)
continue
}
base := name[:len(name)-len(".log")]
@@ -65,7 +70,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if idx < 0 {
continue
}
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
byName[base[idx+1:]] = append(byName[base[idx+1:]], summary)
}
for _, job := range jobs {
@@ -76,80 +81,71 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if len(files) == 0 {
continue
}
// The timestamp prefix sorts chronologically, so a lexical sort puts the
// oldest first; keep the newest maxFiles to honor the retention bound.
sort.Strings(files)
// The timestamp prefix sorts chronologically, so a lexical sort by file
// name puts the oldest first; keep the newest maxFiles to honor the
// retention bound.
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
if maxFiles > 0 && len(files) > maxFiles {
files = files[len(files)-maxFiles:]
}
result[job.ID] = aggregateLogStats(logsDir, files)
result[job.ID] = aggregateLogStats(files)
}
return result
}
// aggregateLogStats folds the header of each log file (oldest first) into one
// SeededStats. Files lacking a duration line contribute to the run/fail counts
// but not to the duration aggregates.
func aggregateLogStats(logsDir string, files []string) SeededStats {
// aggregateLogStats folds the already-read header of each log file (oldest
// first) into one SeededStats. Files lacking a duration line contribute to the
// run/fail counts but not to the duration aggregates.
func aggregateLogStats(files []logSummary) SeededStats {
var stats SeededStats
var durationSum int64
var durationCount int
for _, file := range files {
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
stats.RunCount++
if state == "Failed" {
if file.state == "Failed" {
stats.FailCount++
}
if hasDuration {
if file.hasDuration {
// Files are oldest first, so the last assignment is the newest run.
stats.LastDurationMS = durationMS
if durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = durationMS
stats.LastDurationMS = file.durationMS
if file.durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = file.durationMS
}
durationSum += durationMS
durationSum += file.durationMS
durationCount++
}
}
if durationCount > 0 {
stats.TimedRunCount = durationCount
stats.DurationSumMS = durationSum
stats.AvgDurationMS = durationSum / int64(durationCount)
}
return stats
}
// readLogJobID reads the job_id field from a log file header.
func readLogJobID(path string) (int, bool) {
file, err := os.Open(path)
if err != nil {
return 0, false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
id, err := strconv.Atoi(strings.TrimSpace(rest))
if err != nil {
return 0, false
}
return id, true
}
}
return 0, false
// logSummary is everything SeedStats needs from one run log: the file name it
// sorts by, which job wrote it, how the run ended, and how long it took.
type logSummary struct {
name string
jobID int
hasJobID bool
state string
durationMS int64
hasDuration bool
}
// readLogHeader reads the "state" and "duration" fields from a log file's
// header (the lines before the first blank line). hasDuration reports whether a
// well-formed duration line was present, distinguishing a legacy duration-less
// log from one that genuinely recorded a zero-millisecond run.
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
// readLogSummary reads the job_id, state, and duration fields from a log file's
// header (the lines before the first blank line) in a single pass, so seeding
// opens each log once rather than once to find its job and again to read its
// result. The has* flags report whether a well-formed line was present,
// distinguishing a legacy log written before the field existed from one that
// genuinely recorded a zero value. An unreadable file yields a zero summary,
// which falls back to matching by the job name in the file name.
func readLogSummary(path string) logSummary {
var summary logSummary
file, err := os.Open(path)
if err != nil {
return "", 0, false
return summary
}
defer file.Close()
@@ -159,14 +155,19 @@ func readLogHeader(path string) (state string, durationMS int64, hasDuration boo
if line == "" {
break // end of header
}
if rest, ok := strings.CutPrefix(line, "state: "); ok {
state = strings.TrimSpace(rest)
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
if id, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil {
summary.jobID = id
summary.hasJobID = true
}
} else if rest, ok := strings.CutPrefix(line, "state: "); ok {
summary.state = strings.TrimSpace(rest)
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
durationMS = value
hasDuration = true
summary.durationMS = value
summary.hasDuration = true
}
}
}
return state, durationMS, hasDuration
return summary
}
+4 -20
View File
@@ -22,7 +22,7 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
content.WriteString("\n")
}
if durationMS >= 0 {
content.WriteString("state: " + state + "\nduration: " + itoa(durationMS) + "\n\n")
content.WriteString("state: " + state + "\nduration: " + strconv.FormatInt(durationMS, 10) + "\n\n")
} else {
content.WriteString("state: " + state + "\n\n")
}
@@ -31,25 +31,6 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
}
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
buf := make([]byte, 0, 20)
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
func TestSeedStatsBasic(t *testing.T) {
dir := t.TempDir()
job := domain.Job{ID: 1, Name: "Build"}
@@ -80,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
+143 -26
View File
@@ -16,6 +16,28 @@ type Store struct {
Config domain.Config
}
// 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 {
return domain.DefaultConfig().KeepRunningInTray
}
config, err := loadOrCreateConfig(paths)
if err != nil {
return domain.DefaultConfig().KeepRunningInTray
}
return config.KeepRunningInTray
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
@@ -50,19 +72,47 @@ func OpenStore() (*Store, []domain.Job, error) {
return store, jobs, nil
}
func (s *Store) SaveConfig() error {
// PrepareSaveConfig re-resolves the derived paths from the current config and
// snapshots everything the write needs, returning the write itself as a closure.
// It exists so a caller that guards the Store with its own lock can do the file
// I/O — a marshal, an fsync, and a rename — after releasing that lock: the
// snapshot cannot change under the closure, so running it unlocked is safe.
// Prepared writes must be run in the order they were prepared, or an older
// snapshot can land on top of a newer one.
func (s *Store) PrepareSaveConfig() func() error {
s.applyConfigPaths()
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
return err
dir := s.Paths.AppDir
path := s.Paths.ConfigPath
config := s.Config
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(path, config)
}
return writeJSON(s.Paths.ConfigPath, s.Config)
}
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
// copied, so the caller may keep mutating its own slice as soon as this returns.
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
dir := s.Paths.JobsDir
path := s.Paths.JobsPath
snapshot := make([]domain.Job, len(jobs))
copy(snapshot, jobs)
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
}
}
func (s *Store) SaveConfig() error {
return s.PrepareSaveConfig()()
}
func (s *Store) SaveJobs(jobs []domain.Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeJSON(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
return s.PrepareSaveJobs(jobs)()
}
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
@@ -101,12 +151,12 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
// "keep everything" (see runner.CleanupLogs), not a missing value, so
// backfilling it here would make that choice impossible to persist. A config
// written before either field existed already carries 0 from json.Unmarshal
// leaving the DefaultConfig() value in config untouched, so old files still
// pick up 100 / 30 without an explicit backfill.
if config.ExecutionMode == "" {
config.ExecutionMode = domain.ExecutionModeParallel
}
@@ -118,7 +168,10 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// the setting impossible to persist. Negative values are rejected by
// app.validateConfig before they can be saved.
if config.Theme == "" {
config.Theme = domain.ThemeDefault
config.Theme = domain.ThemeGoSentry
}
if config.Theme == "default" {
config.Theme = domain.ThemeSystem
}
return config, nil
}
@@ -151,8 +204,9 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
if found {
return jobs, nil
}
// Seed harmless sample jobs so a new user can immediately see scheduled
// and manual execution without inventing a command.
// Seed sample jobs so a new user can immediately see scheduled and manual
// execution without inventing a command. The failure sample stays disabled
// so it does not spam notifications; Run now still works for testing.
jobs = defaultJobs()
normalizeJobs(jobs)
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
@@ -160,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
}
@@ -194,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,
@@ -211,7 +275,8 @@ func (s *Store) applyConfigPaths() {
}
func writeJSON(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
@@ -221,10 +286,47 @@ func writeJSON(path string, value any) error {
// A trailing newline keeps the file friendly to editors and diff tools that
// expect text files to end with one.
data = append(data, '\n')
// WriteFile replaces the full file instead of patching it in place. For small
// JSON files this is simpler and prevents stale keys from older versions from
// lingering after the schema changes.
return os.WriteFile(path, data, 0o644)
return writeFileAtomic(dir, path, data, 0o644)
}
// writeFileAtomic writes data to a temp file in dir, syncs it, then renames it
// over path. Rename is atomic within a volume on both supported platforms, so
// a crash, a power loss, or the process being killed mid-write can never leave
// path holding a truncated or empty file the way a direct os.WriteFile could.
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpPath := tmp.Name()
// Any failure past this point must remove the temp file rather than leave
// it behind for the next write to trip over.
success := false
defer func() {
if !success {
os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, perm); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
success = true
return nil
}
func defaultJobs() []domain.Job {
@@ -252,9 +354,24 @@ func defaultJobs() []domain.Job {
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
},
{
ID: 4,
Name: "Failure notification test",
Folder: "Examples",
Schedule: "@every 1m",
Command: failCommand(),
Enabled: false,
},
}
}
func failCommand() string {
if runtime.GOOS == "windows" {
return "exit /b 1"
}
return "exit 1"
}
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
+180 -2
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{
@@ -171,8 +216,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
if got.DefaultTimeoutSeconds != 0 {
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
}
if got.Theme != domain.ThemeDefault {
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault)
if got.Theme != domain.ThemeGoSentry {
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeGoSentry)
}
if got.JobListView != domain.JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
@@ -183,6 +228,98 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
}
}
// TestLoadOrCreateConfigPreservesZeroRetentionLimits verifies that 0 in
// max_log_files / max_log_age_days is read back as 0 ("keep everything"), not
// backfilled to the 100 / 30 defaults, since a config that already has the
// field set is not the "field is missing" case loadOrCreateConfig backfills.
func TestLoadOrCreateConfigPreservesZeroRetentionLimits(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.DefaultConfig()
want.MaxLogFiles = 0
want.MaxLogAgeDays = 0
if err := writeJSON(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.MaxLogFiles != 0 {
t.Errorf("MaxLogFiles: got %d, want 0 (unlimited)", got.MaxLogFiles)
}
if got.MaxLogAgeDays != 0 {
t.Errorf("MaxLogAgeDays: got %d, want 0 (unlimited)", got.MaxLogAgeDays)
}
}
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
// jobs.json is created with the sample jobs from defaultJobs, so a new user
// sees scheduled and manual execution without inventing a command.
func TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.json")
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
want := defaultJobs()
if len(got) != len(want) {
t.Fatalf("got %d jobs, want %d", len(got), len(want))
}
for i := range want {
if got[i].Name != want[i].Name || got[i].Schedule != want[i].Schedule || got[i].Command != want[i].Command || got[i].Enabled != want[i].Enabled {
t.Errorf("job %d = %+v, want %+v", i, got[i], want[i])
}
}
// The function must have written the seeded jobs to jobs.json.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("jobs.json should have been created: %v", err)
}
var file domain.JobsFile
if err := json.Unmarshal(data, &file); err != nil {
t.Fatal(err)
}
if len(file.Jobs) != len(want) {
t.Errorf("jobs.json has %d jobs, want %d", len(file.Jobs), len(want))
}
}
// TestLoadOrCreateConfigMigratesLegacyThemeDefault covers a gosentry.json that
// still stores the retired "default" theme value: load normalizes it to system.
func TestLoadOrCreateConfigMigratesLegacyThemeDefault(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
legacy := map[string]any{
"jobs_file": "jobs.json",
"logs_dir": "logs",
"max_log_files": 100,
"max_log_age_days": 30,
"theme": "default",
}
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.Theme != domain.ThemeSystem {
t.Errorf("migrated Theme = %q, want %q", got.Theme, domain.ThemeSystem)
}
}
// TestLoadOrCreateConfigKeepsZeroTimeoutOnReload guards the "0 = no timeout"
// setting against being normalized away when an existing gosentry.json is read
// back. Loading must not treat 0 as a missing value.
@@ -367,3 +504,44 @@ func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
}
}
}
// TestWriteJSONReplacesFileAtomically pins the durability fix: writeJSON must
// never truncate the destination in place. It writes through a temp file and
// renames over the target, so a reader can never observe a partially written
// file, and an existing file survives untouched if the marshal fails first.
func TestWriteJSONReplacesFileAtomically(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gosentry.json")
original := domain.DefaultConfig()
original.LogsDir = "logs-original"
if err := writeJSON(path, original); err != nil {
t.Fatal(err)
}
updated := domain.DefaultConfig()
updated.LogsDir = "logs-updated"
if err := writeJSON(path, updated); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var got domain.Config
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if got.LogsDir != "logs-updated" {
t.Fatalf("LogsDir = %q, want %q", got.LogsDir, "logs-updated")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected only the final file in %s, got %v", dir, entries)
}
}
+111 -38
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
@@ -102,23 +84,113 @@ const historyTimeSample = "2026-01-02 15:04:05"
// Detail and Log are free text, so their width tracks the values actually
// present, bounded the same way the Log column always was.
func historyColumnWidths(rows []event) [6]float32 {
jobNames := make([]string, 0, len(rows))
details := make([]string, 0, len(rows))
logNames := make([]string, 0, len(rows))
var content [3][]string
for i := range content {
content[i] = make([]string, 0, len(rows))
}
for _, current := range rows {
jobNames = append(jobNames, current.JobName)
details = append(details, current.Detail)
logNames = append(logNames, logFileName(current.LogFile))
for i, value := range historyContentValues(current) {
content[i] = append(content[i], value)
}
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
return [6]float32{
textWidth(historyTimeSample) + cellPadding(),
textColumnWidth(historyTriggerSamples, min, max),
textColumnWidth(jobNames, min, max),
textColumnWidth(historyStateSamples, min, max),
textColumnWidth(details, min, max),
textColumnWidth(logNames, min, max),
widths := [6]float32{
0: textWidth(historyTimeSample) + cellPadding(),
1: textColumnWidth(historyTriggerSamples, min, max),
3: textColumnWidth(historyStateSamples, min, max),
}
for i, col := range historyContentCols {
widths[col] = textColumnWidth(content[i], min, max)
}
return widths
}
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
// job's own activity list. History is never persisted and every record carries
// the run's full captured output, so an app left running in the tray — the mode
// GoSentry is designed for — would otherwise hold every record of every run
// forever, and pay a full resort plus a full column-width rescan on each new
// one. One job on @every 10s produces ~8 600 records a day.
const maxHistoryRows = 1000
// historyLog is the session History: the capped record list plus the column
// widths measured from it. It exists so the widths can be folded in one record
// at a time instead of being recomputed from every row on every event, which
// is what made the per-event cost grow with the number of rows.
type historyLog struct {
records []event
widths [6]float32
// textSize and padding are the theme metrics widths were last measured at.
// A theme change invalidates every measurement, so it forces a full rescan
// rather than folding new records into stale numbers.
textSize float32
padding float32
}
func newHistoryLog(records []event) *historyLog {
h := &historyLog{records: trimHistory(records)}
h.rescan()
return h
}
// trimHistory drops the oldest records past the cap. The tail of the backing
// array is zeroed because a dropped record holds the run's whole output, which
// would otherwise stay reachable until the slice happens to be reallocated.
func trimHistory(records []event) []event {
if len(records) <= maxHistoryRows {
return records
}
kept := copy(records, records[len(records)-maxHistoryRows:])
for i := kept; i < len(records); i++ {
records[i] = event{}
}
return records[:kept]
}
// add appends one record and widens any content-measured column the record
// does not fit. Widths only ever grow within a theme: a column is never
// narrowed when a record ages out, because the rows still on screen were laid
// out against the wider value.
func (h *historyLog) add(record event) {
h.records = trimHistory(append(h.records, record))
if h.stale() {
h.rescan()
return
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
for i, value := range historyContentValues(record) {
if width := textColumnWidth([]string{value}, min, max); width > h.widths[historyContentCols[i]] {
h.widths[historyContentCols[i]] = width
}
}
}
// columnWidths returns the widths to apply to the table, rescanning every
// record only when the theme's text metrics have changed since the last scan.
func (h *historyLog) columnWidths() [6]float32 {
if h.stale() {
h.rescan()
}
return h.widths
}
func (h *historyLog) stale() bool {
return theme.TextSize() != h.textSize || cellPadding() != h.padding
}
func (h *historyLog) rescan() {
h.textSize, h.padding = theme.TextSize(), cellPadding()
h.widths = historyColumnWidths(h.records)
}
// historyContentCols are the columns whose width follows the values actually
// present, in the order historyContentValues returns them. Both the
// incremental fold in add and the full scan in historyColumnWidths go through
// this pair, so they cannot disagree about which columns follow content.
var historyContentCols = [3]int{2, 4, 5}
func historyContentValues(record event) [3]string {
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
}
// historyHeader is a bold tappable label used in the History table header row.
@@ -156,7 +228,7 @@ func (h *historyHeader) SetText(text string) {
// Time caption is built per update because it carries the sort direction arrow.
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
func newHistoryView(events *[]event) (*fyne.Container, func()) {
func newHistoryView(log *historyLog) (*fyne.Container, func()) {
descending := false
headerText := func(id widget.TableCellID) string {
if id.Row < 0 && id.Col == 0 {
@@ -179,7 +251,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
// per redraw: at build time, on a sort toggle, and from refresh().
var rows []event
resort := func() {
rows = append(rows[:0], (*events)...)
rows = append(rows[:0], log.records...)
sort.SliceStable(rows, func(left int, right int) bool {
if descending {
return rows[left].Time > rows[right].Time
@@ -224,16 +296,17 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
table.Unselect(id)
}
setColumnWidths := func() {
for col, width := range historyColumnWidths(rows) {
for col, width := range log.columnWidths() {
table.SetColumnWidth(col, width)
}
}
setColumnWidths()
// refresh re-reads the event list into the sorted snapshot and recomputes
// every content-fit column width before redrawing, so newly recorded events
// appear in the current sort order and longer values widen their column
// instead of being truncated.
// refresh re-reads the event list into the sorted snapshot and re-applies
// the column widths before redrawing, so newly recorded events appear in
// the current sort order and longer values widen their column instead of
// being truncated. The widths come from historyLog, which folded each new
// record in as it arrived — this does not rescan every row.
refresh := func() {
resort()
setColumnWidths()
+95 -31
View File
@@ -1,12 +1,11 @@
package ui
import (
"strconv"
"strings"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
@@ -56,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",
@@ -144,7 +118,8 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
{Time: "2026-06-01 11:00:00", JobName: "B"},
{Time: "2026-06-01 12:00:00", JobName: "C"},
}
content, refresh := newHistoryView(&events)
log := newHistoryLog(events)
content, refresh := newHistoryView(log)
table, ok := content.Objects[0].(*widget.Table)
if !ok {
t.Fatal("history view does not wrap a table")
@@ -192,7 +167,7 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
// A new run arrives while the table is sorted newest-first: it must be
// counted and placed in the order currently on screen, not the build-time one.
events = append(events, event{Time: "2026-06-01 13:00:00", JobName: "D"})
log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"})
refresh()
assertOrder("descending after refresh", "D", "C", "B", "A")
@@ -207,8 +182,7 @@ func TestHistoryCellTemplateIsPlainText(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
var events []event
content, _ := newHistoryView(&events)
content, _ := newHistoryView(newHistoryLog(nil))
table := content.Objects[0].(*widget.Table)
label, ok := table.CreateCell().(*widget.Label)
if !ok {
@@ -292,6 +266,96 @@ func TestHistoryColumnsFitTheirContent(t *testing.T) {
check("scaled theme")
}
// TestHistoryLogCapsRecords is the regression guard for the unbounded History
// list: an app left in the tray records thousands of runs a day, each carrying
// the run's whole captured output, so the list must drop the oldest instead of
// growing forever.
func TestHistoryLogCapsRecords(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for i := 0; i < maxHistoryRows+25; i++ {
log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)})
}
if len(log.records) != maxHistoryRows {
t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows)
}
if got, want := log.records[0].JobName, "Job 25"; got != want {
t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want)
}
last := log.records[len(log.records)-1].JobName
if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want {
t.Errorf("newest record = %q, want %q", last, want)
}
// A list handed in above the cap is trimmed too, not only one grown into it.
oversized := make([]event, maxHistoryRows+10)
if got := len(newHistoryLog(oversized).records); got != maxHistoryRows {
t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows)
}
}
// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while
// every measured record is still in the list, folding each one in as it
// arrives must give exactly what rescanning every row would, or the cheaper
// path would clip values the old one showed.
func TestHistoryLogWidthsMatchAFullScan(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for _, record := range []event{
{Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`},
{Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`},
{Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`},
} {
log.add(record)
}
if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want {
t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want)
}
}
// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the
// rule: widths only grow. Dropping the record that set a column's width must
// not narrow the column, because the rows on screen were laid out against it.
func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
log.add(event{Time: "1", JobName: "A job name long enough to widen its column"})
widest := log.columnWidths()[2]
for i := 0; i < maxHistoryRows; i++ {
log.add(event{Time: "t", JobName: "x"})
}
if got := log.columnWidths()[2]; got != widest {
t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest)
}
}
// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold
// cannot handle: every stored width was measured at the old text size, so a
// theme change has to fall back to a full rescan.
func TestHistoryLogRescansOnThemeChange(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog([]event{
{Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"},
})
before := log.columnWidths()
testApp.Settings().SetTheme(test.NewTheme())
after := log.columnWidths()
if after == before {
t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics")
}
if want := historyColumnWidths(log.records); after != want {
t.Errorf("widths after theme change = %v, want the rescanned %v", after, want)
}
}
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
ev := newEvent(1, "Job", "OK", "detail")
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
+157 -306
View File
@@ -1,8 +1,6 @@
package ui
import (
"fmt"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
@@ -22,334 +20,187 @@ const noFolder = "No folder"
// view; this panel is a quick at-a-glance summary anchored below the output.
const maxJobActivityRows = 3
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
// (list mode and the scheduler pause label), and the jobsViewState the widgets
// read. It replaces a single constructor whose dozen closures shared seven
// mutable locals — the state each handler touches is now named on the struct
// rather than captured, and the invariants that used to be maintained by hand in
// five places live on jobsViewState.
type jobsView struct {
w fyne.Window
svc *app.Service
state *jobsViewState
dp *detailsPanel
list *widget.List
folderSelect *widget.Select
viewButton *widget.Button
stopAllButton *widget.Button
schedulerState *widget.Label
// listView and paused mirror Service-owned config so the widgets can be
// relabelled without a round trip. Both are re-read from the Service on every
// refresh; neither is a source of truth.
listView domain.JobListView
paused bool
}
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// It returns the assembled panel and a refresh function the caller invokes
// whenever the service state may have changed (e.g., from the event subscriber
// in mainwindow.go). The refresh function re-reads the service snapshot and
// redraws all widgets in the jobs view; it does NOT touch history or settings.
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
jobs := svc.Jobs()
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
syncFromService := func() {
jobs = svc.Jobs()
for id := range runtimes {
delete(runtimes, id)
}
for _, current := range jobs {
if rt := svc.Runtime(current.ID); rt != nil {
runtimes[current.ID] = rt
}
}
}
syncFromService()
runtimeFor := func(index int) *domain.JobRuntime {
if index < 0 || index >= len(jobs) {
return &domain.JobRuntime{}
}
if rt := runtimes[jobs[index].ID]; rt != nil {
return rt
}
return &domain.JobRuntime{}
config := svc.Config()
v := &jobsView{
w: w,
svc: svc,
state: newJobsViewState(svc),
listView: config.JobListView,
paused: config.Paused,
}
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
v.updateDetails()
selected := 0
if len(jobs) == 0 {
selected = -1
}
selectedFolder := allFolders
schedulerPaused := svc.Store().Config.Paused
listView := svc.Store().Config.JobListView
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
// Build order follows what refresh() touches: the folder select fires its
// OnChanged from SetSelected below, which refreshes, so every widget that
// refresh() reaches has to exist by then.
v.list = v.newList()
v.viewButton = v.newViewToggle()
globalControls := v.newGlobalControls()
v.folderSelect = v.newFolderSelect()
v.folderSelect.SetSelected(v.state.folder)
v.syncListSelection()
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
if selected >= 0 {
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
} else {
dp.clear()
}
return v.assemble(globalControls), v.refresh
}
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
// A folder filter can temporarily leave no selectable rows. Clearing
// the details panel avoids showing stale information for a hidden job.
dp.clear()
return
}
selected = index
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
}
// refresh re-reads the Service and redraws the whole view. It is the single
// entry point for "something changed": the toolbar handlers call it after a
// successful operation, and mainwindow's event observer calls it for everything
// else.
func (v *jobsView) refresh() {
v.state.sync()
// The pause state is Service-owned and can change from outside this view, so
// 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.list.Refresh()
v.syncListSelection()
}
// list and folderSelect are declared early so closures below can reference
// them before the widget.NewList / widget.NewSelect calls assign the values.
var list *widget.List
var folderSelect *widget.Select
refreshView := func() {
syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
dp.logs.Refresh()
if list != nil {
list.Refresh()
}
// updateDetails repopulates the details pane from the current selection.
func (v *jobsView) updateDetails() {
current, ok := v.state.selected()
if !ok {
// A folder filter can temporarily leave no selectable rows. Clearing the
// details panel avoids showing stale information for a hidden job.
v.dp.clear()
return
}
// Overlap policy and the default timeout are global settings that can change
// from the Settings tab while this view is open, so they are re-read on every
// update rather than captured once at construction.
config := v.svc.Config()
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
}
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
// border layout both skip hidden children when measuring.
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
if listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
// syncListSelection points the list's highlight at the selected job. It is what
// keeps the highlight and the details pane describing the same job when the row
// a job sits in moves — a job created or deleted above it, a folder filter
// applied, or a different jobs file adopted. widget.List.Select returns early
// when the row is already highlighted, so calling this on every refresh does not
// fight the user's scrolling.
func (v *jobsView) syncListSelection() {
row := v.state.displayRow()
if row < 0 {
v.list.UnselectAll()
return
}
v.list.Select(row)
}
list = widget.NewList(
func() int { return len(filteredJobs) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge. Labels default to TextWrapOff, which grows
// the widget to fit instead.
name.Truncation = fyne.TextTruncateClip
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container)
// NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
// rebuildFolders re-derives the folder filter's options from the current jobs.
// Creating, editing, and deleting a job can all add or remove a folder.
func (v *jobsView) rebuildFolders() {
v.folderSelect.Options = folderOptions(v.state.jobs)
v.folderSelect.Refresh()
}
current := jobs[filteredJobs[id]]
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
statusText := app.StatusText(current, runtimes[current.ID])
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
applyRowMode(inlineStatus, meta, status)
},
)
list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(filteredJobs) {
updateDetails(-1)
return
}
updateDetails(filteredJobs[id])
}
if len(filteredJobs) > 0 && selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
return
}
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 {
// The "No folder" filter is intentionally allowed to be empty. It is a
// real filter choice, not an error state, so the selection is cleared.
// This path returns without reaching refreshView(), so it is the one
// place the list has to be redrawn by hand.
selected = -1
updateDetails(-1)
list.Refresh()
return
}
selected = filteredJobs[0]
list.Select(0)
refreshView()
})
folderSelect.SetSelected(selectedFolder)
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
viewButton.OnTapped = func() {
next := nextJobListView(listView)
listView = next
if err := svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
listView = nextJobListView(next)
dialog.ShowError(err, w)
return
}
viewButton.SetText(viewToggleText(listView))
viewButton.SetIcon(viewToggleIcon(listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
list.Refresh()
}
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
created, err := svc.CreateJob(saved)
if err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
targetFolder := filterValue(created.Folder)
if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder)
}
selected = indexOfID(jobs, created.ID)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Select(app.DisplayIndex(filteredJobs, selected))
refreshView()
})
})
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
saved.ID = jobs[selected].ID
if err := svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
refreshView()
})
})
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
if err := svc.RunNow(jobs[selected].ID); err != nil {
dialog.ShowError(err, w)
return
}
refreshView()
})
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
if schedulerPaused {
stopAllText, stopAllIcon = "Enable auto", theme.MediaPlayIcon()
}
schedulerStateText := "Scheduler running"
if schedulerPaused {
schedulerStateText = "Scheduler paused"
}
schedulerState := widget.NewLabel(schedulerStateText)
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. Revert if the save fails.
schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused
dialog.ShowError(err, w)
return
}
if schedulerPaused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Enable auto")
stopAllButton.SetIcon(theme.MediaPlayIcon())
} else {
schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Disable auto")
stopAllButton.SetIcon(theme.MediaPauseIcon())
}
refreshView()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
current := jobs[selected]
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, w)
return
}
refreshView()
})
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
deleted := jobs[selected]
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
if err := svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 && selectedFolder != allFolders {
selectedFolder = allFolders
folderSelect.SetSelected(allFolders)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
}
if len(filteredJobs) == 0 {
selected = -1
} else {
selected = filteredJobs[0]
}
if selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
refreshView()
}, w)
})
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
// the details pane into the master/detail split the tab shows.
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
// The whole filter is one row: caption on the left, view toggle on the right,
// select filling what is left. The border layout gives both edges their
// MinSize, so the header is a line shorter than a stacked caption would make it.
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
filterRow := container.NewBorder(nil, nil, folderCaption, viewButton, folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
// A split rather than a Border left slot: the border pinned the sidebar at its
// MinSize forever, so the user could never trade list width for detail width.
// The divider lets either pane grow, and neither can be dragged below its own
// content minimum.
panel := container.NewHSplit(sidebar, container.NewPadded(dp.container()))
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
return panel, refreshView
return panel
}
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
// and, when the selected job is no longer visible, moves the selection to the
// first row that is (see jobsViewState.applyFilter).
func (v *jobsView) newFolderSelect() *widget.Select {
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
if value == "" {
return
}
v.state.applyFilter(value)
v.refresh()
})
}
// newGlobalControls builds the pause control row that sits above the filter.
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
v.schedulerState = widget.NewLabel("")
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
v.applySchedulerState(v.paused)
v.stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. refresh re-derives the
// pause state from the Service, so a failed save leaves the control showing
// what actually happened.
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
}
// The row sits directly under the tab bar with no AppTabs inset, while the
// default VBox gap below it is one theme padding — add the same on top so
// the button is not flush against the tabs.
return container.New(
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
)
}
// applySchedulerState is the one place that draws the pause control and its
// status text from a pause value, so refresh can drive it from whatever the
// Service reports instead of only the tap handler mirroring its own toggle.
func (v *jobsView) applySchedulerState(paused bool) {
v.paused = paused
if paused {
v.schedulerState.SetText("Scheduler paused")
v.stopAllButton.SetText("Enable auto")
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
return
}
v.schedulerState.SetText("Scheduler running")
v.stopAllButton.SetText("Disable auto")
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
}
+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)
+114
View File
@@ -0,0 +1,114 @@
package ui
import (
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// newList builds the sidebar's job list. Rows are drawn from jobsViewState's
// filtered view, so the row index the widget reports is a position in the
// filter, never an index into the job snapshot.
func (v *jobsView) newList() *widget.List {
list := widget.NewList(
func() int { return len(v.state.filtered) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge. Labels default to TextWrapOff, which grows
// the widget to fit instead.
name.Truncation = fyne.TextTruncateClip
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
v.applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
current, ok := v.state.jobAt(int(id))
if !ok {
return
}
row := item.(*fyne.Container)
// NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
statusText := app.StatusText(current, v.state.runtime(current.ID))
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
v.applyRowMode(inlineStatus, meta, status)
},
)
list.OnSelected = func(id widget.ListItemID) {
v.state.selectRow(int(id))
v.updateDetails()
}
return list
}
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
// border layout both skip hidden children when measuring.
func (v *jobsView) applyRowMode(inlineStatus, meta, status fyne.CanvasObject) {
if v.listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
}
// newViewToggle builds the compact/detailed switch that sits at the right edge
// of the filter row.
func (v *jobsView) newViewToggle() *widget.Button {
button := widget.NewButtonWithIcon(viewToggleText(v.listView), viewToggleIcon(v.listView), nil)
button.OnTapped = func() {
next := nextJobListView(v.listView)
v.listView = next
if err := v.svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
v.listView = nextJobListView(next)
dialog.ShowError(err, v.w)
return
}
button.SetText(viewToggleText(v.listView))
button.SetIcon(viewToggleIcon(v.listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
v.list.Refresh()
}
return button
}
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
func viewToggleIcon(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}
+156
View File
@@ -0,0 +1,156 @@
package ui
import (
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// jobsViewState is the model behind the Jobs tab: the snapshot of the Service's
// jobs and runtimes, the folder filter, and the selection. The widgets in
// jobsView read it and never keep a second copy of any of it.
//
// The selection is a job ID, not an index into the snapshot. Every path that
// changes the job list replaces that snapshot underneath the view — create,
// delete, and edit do it from this view's own handlers, but adopting a different
// jobs file does it from the Service, and the view only learns about it through
// the refresh that JobsLoaded triggers. An index that outlives its snapshot then
// points at whichever job happens to sit there now, so the details pane
// describes one job while the list highlights another. Indexes are derived from
// the ID at render time instead (selectedIndex, displayRow).
type jobsViewState struct {
svc *app.Service
jobs []job
runtimes map[int]*domain.JobRuntime
folder string
// selectedID is 0 when nothing is selected; job IDs start at 1.
selectedID int
// filtered holds the indexes into jobs that the folder filter shows, in list
// row order: filtered[row] is the index of the job drawn in that row.
filtered []int
}
func newJobsViewState(svc *app.Service) *jobsViewState {
s := &jobsViewState{
svc: svc,
runtimes: map[int]*domain.JobRuntime{},
folder: allFolders,
}
s.sync()
return s
}
// sync re-reads the Service snapshot, re-applies the folder filter, and
// re-resolves the selection against the new list. It is the only place the view
// reads job state from the Service.
func (s *jobsViewState) sync() {
s.jobs = s.svc.Jobs()
clear(s.runtimes)
for _, current := range s.jobs {
if rt := s.svc.Runtime(current.ID); rt != nil {
s.runtimes[current.ID] = rt
}
}
s.filtered = filteredJobIndexes(s.jobs, s.folder)
s.resolveSelection()
}
// applyFilter switches the folder filter, keeping the current selection when the
// new filter still shows it. A filter that matches nothing — "No folder" with no
// such job — is a real filter choice, not an error state, so it simply leaves
// nothing selected.
func (s *jobsViewState) applyFilter(folder string) {
s.folder = folder
s.filtered = filteredJobIndexes(s.jobs, s.folder)
if !s.visible(s.selectedID) {
s.selectedID = 0
}
s.resolveSelection()
}
// resolveSelection drops a selection whose job is gone and falls back to the
// first visible row, so the details pane never describes a job the current
// snapshot no longer holds.
func (s *jobsViewState) resolveSelection() {
if s.selectedID != 0 && indexOfID(s.jobs, s.selectedID) < 0 {
s.selectedID = 0
}
if s.selectedID == 0 && len(s.filtered) > 0 {
s.selectedID = s.jobs[s.filtered[0]].ID
}
}
// selectByID records the selection directly, for handlers that know the job they
// want selected (a newly created job, for instance) rather than its row.
func (s *jobsViewState) selectByID(id int) {
s.selectedID = id
}
// selectRow records the selection from a list row, which is what widget.List
// reports through OnSelected.
func (s *jobsViewState) selectRow(row int) {
current, ok := s.jobAt(row)
if !ok {
s.selectedID = 0
return
}
s.selectedID = current.ID
}
// selected returns the selected job, or false when nothing is selected.
func (s *jobsViewState) selected() (job, bool) {
index := s.selectedIndex()
if index < 0 {
return job{}, false
}
return s.jobs[index], true
}
// selectedIndex resolves the selected ID to an index into the current snapshot,
// or -1 when nothing is selected.
func (s *jobsViewState) selectedIndex() int {
if s.selectedID == 0 {
return -1
}
return indexOfID(s.jobs, s.selectedID)
}
// displayRow maps the selection onto a list row, or -1 when nothing is selected
// or the filter hides the selected job — so a caller unselects rather than
// highlighting an unrelated row.
func (s *jobsViewState) displayRow() int {
index := s.selectedIndex()
if index < 0 || !s.visible(s.selectedID) {
return -1
}
return app.DisplayIndex(s.filtered, index)
}
// jobAt returns the job drawn in the given list row.
func (s *jobsViewState) jobAt(row int) (job, bool) {
if row < 0 || row >= len(s.filtered) {
return job{}, false
}
return s.jobs[s.filtered[row]], true
}
// runtime returns a job's runtime, or an empty one when the Service has none
// yet, so callers can read it without a nil check.
func (s *jobsViewState) runtime(id int) *domain.JobRuntime {
if rt := s.runtimes[id]; rt != nil {
return rt
}
return &domain.JobRuntime{}
}
// visible reports whether the folder filter shows the given job.
func (s *jobsViewState) visible(id int) bool {
if id == 0 {
return false
}
for _, index := range s.filtered {
if s.jobs[index].ID == id {
return true
}
}
return false
}
+198
View File
@@ -0,0 +1,198 @@
package ui
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// newStateForTest builds a jobsViewState over a Service holding the given jobs.
// No Fyne app is needed: the state is the view's model and touches no widgets.
func newStateForTest(t *testing.T, jobs []domain.Job) (*jobsViewState, *app.Service) {
t.Helper()
svc := app.NewService(newTestStore(t), jobs)
t.Cleanup(svc.Stop)
return newJobsViewState(svc), svc
}
func threeJobs() []domain.Job {
return []domain.Job{
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
{ID: 3, Name: "Third", Folder: "Reports", Schedule: "@every 3m", Command: "echo three", Enabled: true},
}
}
func selectedName(t *testing.T, s *jobsViewState) string {
t.Helper()
current, ok := s.selected()
if !ok {
return ""
}
return current.Name
}
// TestJobsViewStateSelectsTheFirstJob pins the opening state: the first row is
// selected so the details pane is never blank when there is something to show.
func TestJobsViewStateSelectsTheFirstJob(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
if got := selectedName(t, s); got != "First" {
t.Errorf("selected job = %q, want %q", got, "First")
}
if got := s.displayRow(); got != 0 {
t.Errorf("displayRow = %d, want 0", got)
}
}
func TestJobsViewStateEmptyListSelectsNothing(t *testing.T) {
s, _ := newStateForTest(t, nil)
if _, ok := s.selected(); ok {
t.Error("an empty job list should leave nothing selected")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow with nothing selected = %d, want -1", got)
}
}
// TestJobsViewStateSelectionFollowsTheJobNotTheRow is the regression guard for
// the selection defect: the selection is a job ID, so a job removed above the
// selected one must not slide the selection onto its neighbour. The deletion
// goes through the Service rather than the Delete button, which is how the view
// learns about a job list that changed underneath it (a different jobs file
// adopted, or any other broad JobChanged).
func TestJobsViewStateSelectionFollowsTheJobNotTheRow(t *testing.T) {
s, svc := newStateForTest(t, threeJobs())
s.selectRow(2)
if got := selectedName(t, s); got != "Third" {
t.Fatalf("selected job after selecting row 2 = %q, want %q", got, "Third")
}
if err := svc.DeleteJob(1); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
s.sync()
if got := selectedName(t, s); got != "Third" {
t.Errorf("selected job after the first job was removed = %q, want it still on %q", got, "Third")
}
if got := s.displayRow(); got != 1 {
t.Errorf("displayRow = %d, want the row %q moved to (1)", got, "Third")
}
}
// TestJobsViewStateDropsSelectionWhenItsJobIsGone covers the other half: a
// selected job that no longer exists falls back to the first visible row instead
// of describing whichever job inherited its position.
func TestJobsViewStateDropsSelectionWhenItsJobIsGone(t *testing.T) {
s, svc := newStateForTest(t, threeJobs())
s.selectRow(1)
if err := svc.DeleteJob(2); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
s.sync()
if got := selectedName(t, s); got != "First" {
t.Errorf("selected job after deleting the selected one = %q, want the fallback %q", got, "First")
}
}
func TestJobsViewStateApplyFilter(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
// The selected job is in the folder being filtered to, so it stays selected.
s.selectRow(2)
s.applyFilter("Reports")
if got := selectedName(t, s); got != "Third" {
t.Errorf("selection after filtering to its own folder = %q, want %q", got, "Third")
}
if got := s.displayRow(); got != 0 {
t.Errorf("displayRow inside the filter = %d, want 0", got)
}
// Filtering to a folder that hides it moves the selection to the first row
// that folder does show.
s.applyFilter("Maintenance")
if got := selectedName(t, s); got != "First" {
t.Errorf("selection after filtering it away = %q, want %q", got, "First")
}
// "No folder" matches the one job without one.
s.applyFilter(noFolder)
if got := selectedName(t, s); got != "Second" {
t.Errorf("selection under the %q filter = %q, want %q", noFolder, got, "Second")
}
s.applyFilter(allFolders)
if got := len(s.filtered); got != 3 {
t.Errorf("rows under %q = %d, want 3", allFolders, got)
}
}
// TestJobsViewStateEmptyFilterSelectsNothing pins that a filter matching no job
// is a filter choice, not an error state: nothing is selected, and nothing is
// highlighted either.
func TestJobsViewStateEmptyFilterSelectsNothing(t *testing.T) {
s, _ := newStateForTest(t, []domain.Job{
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
})
s.applyFilter(noFolder)
if _, ok := s.selected(); ok {
t.Error("a filter that matches nothing should leave nothing selected")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow under an empty filter = %d, want -1", got)
}
// The selection comes back when the filter does.
s.applyFilter(allFolders)
if got := selectedName(t, s); got != "First" {
t.Errorf("selection after clearing the filter = %q, want %q", got, "First")
}
}
// TestJobsViewStateHiddenSelectionIsNotHighlighted covers the case the list
// widget cannot express: the selected job still exists but the filter hides it,
// so there is no row to highlight and displayRow must say so rather than fall
// back to row 0.
func TestJobsViewStateHiddenSelectionIsNotHighlighted(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
s.applyFilter("Maintenance")
// Selecting by ID is how the create handler points the view at a job it just
// made; here it reaches the state a hidden-but-selected job would be in.
s.selectByID(3)
if s.visible(3) {
t.Fatal("job 3 should be hidden by the Maintenance filter")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow for a hidden selection = %d, want -1", got)
}
if got := selectedName(t, s); got != "Third" {
t.Errorf("selected job = %q, want it still %q", got, "Third")
}
}
func TestJobsViewStateRuntimeIsNeverNil(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
if rt := s.runtime(99); rt == nil {
t.Error("runtime for an unknown job returned nil, want an empty runtime")
}
}
func TestJobsViewStateJobAtRejectsRowsOutsideTheFilter(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
s.applyFilter("Reports")
if current, ok := s.jobAt(0); !ok || current.Name != "Third" {
t.Errorf("jobAt(0) = (%q, %v), want (%q, true)", current.Name, ok, "Third")
}
if _, ok := s.jobAt(1); ok {
t.Error("jobAt past the last filtered row should report no job")
}
if _, ok := s.jobAt(-1); ok {
t.Error("jobAt(-1) should report no job")
}
}
+103 -33
View File
@@ -1,6 +1,9 @@
package ui
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
@@ -56,48 +59,43 @@ func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
}
}
func TestFilteredJobIndexesAll(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"},
{Folder: ""},
{Folder: "Reports"},
}
got := filteredJobIndexes(jobs, allFolders)
if len(got) != 3 {
t.Errorf("allFolders filter: got %d indexes, want 3", len(got))
}
}
func TestFilteredJobIndexesByNamedFolder(t *testing.T) {
func TestFilteredJobIndexes(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"}, // index 0
{Folder: ""}, // index 1
{Folder: ""}, // index 1 — no folder
{Folder: "Maintenance"}, // index 2
{Folder: "Reports"}, // index 3
{Folder: " "}, // index 4 — blank reads as no folder
}
got := filteredJobIndexes(jobs, "Maintenance")
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
t.Errorf("Maintenance filter: got %v, want [0 2]", got)
cases := []struct {
name string
jobs []domain.Job
filter string
want []int
}{
{"all folders", jobs, allFolders, []int{0, 1, 2, 3, 4}},
{"named folder", jobs, "Maintenance", []int{0, 2}},
{"no folder", jobs, noFolder, []int{1, 4}},
{"empty job list", nil, allFolders, nil},
}
for _, tc := range cases {
got := filteredJobIndexes(tc.jobs, tc.filter)
if !sameIndexes(got, tc.want) {
t.Errorf("%s: filteredJobIndexes(_, %q) = %v, want %v", tc.name, tc.filter, got, tc.want)
}
}
}
func TestFilteredJobIndexesNoFolder(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"}, // index 0 — excluded
{Folder: ""}, // index 1 — no folder → included
{Folder: " "}, // index 2 — blank → included
func sameIndexes(got, want []int) bool {
if len(got) != len(want) {
return false
}
got := filteredJobIndexes(jobs, noFolder)
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("noFolder filter: got %v, want [1 2]", got)
}
}
func TestFilteredJobIndexesEmptySlice(t *testing.T) {
got := filteredJobIndexes(nil, allFolders)
if len(got) != 0 {
t.Errorf("empty job list should return empty indexes, got %v", got)
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestNextJobListViewFlipsBothWays(t *testing.T) {
@@ -383,7 +381,7 @@ func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
// toolbar handlers no longer re-read the service or refresh the list
// themselves, so refreshView alone has to re-snapshot the jobs and repopulate
// themselves, so jobsView.refresh alone has to re-snapshot the jobs and repopulate
// the details pane. If it ever stops doing either, the row renders a stale
// status and the details lose the selection — neither is a compile error.
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
@@ -446,6 +444,78 @@ func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
}
}
// TestJobsViewSelectionSurvivesAJobsFileSwitch is the view-level regression
// guard for the selection defect. Adopting a different jobs file replaces the
// whole list from the Service; the view only hears about it through the refresh
// that JobsLoaded triggers, which is exactly what this test calls. With the
// selection held as a row index, that refresh redrew the details pane from the
// old index — describing whichever job now sat there, or clearing the pane when
// the new list was shorter — while the list's highlight stayed where it was.
func TestJobsViewSelectionSurvivesAJobsFileSwitch(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
w := testApp.NewWindow("test")
defer w.Close()
store := newTestStore(t)
svc := app.NewService(store, []domain.Job{
{ID: 1, Name: "First", Schedule: "@every 1m", Command: "echo one", Enabled: true},
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
{ID: 3, Name: "Third", Schedule: "@every 3m", Command: "echo three", Enabled: true},
})
defer svc.Stop()
content, refresh := newJobsView(w, svc)
w.SetContent(content)
list := jobsList(t, content)
list.Select(2)
if got := jobsDetailsTitle(t, content); got != "Third" {
t.Fatalf("details title after selecting row 2 = %q, want %q", got, "Third")
}
// A second jobs file with different jobs and different IDs, so nothing about
// the old selection can resolve into the new list.
other := []domain.Job{
{ID: 10, Name: "Alpha", Schedule: "@every 1m", Command: "echo alpha", Enabled: true},
{ID: 11, Name: "Beta", Schedule: "@every 2m", Command: "echo beta", Enabled: true},
}
payload, err := json.Marshal(domain.JobsFile{Jobs: other})
if err != nil {
t.Fatalf("marshal jobs: %v", err)
}
if err := os.WriteFile(filepath.Join(store.Paths.AppDir, "other.json"), payload, 0o644); err != nil {
t.Fatalf("write jobs file: %v", err)
}
config := svc.Config()
config.JobsFile = "other.json"
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
refresh()
if got := jobsDetailsTitle(t, content); got != "Alpha" {
t.Errorf("details title after the switch = %q, want the first job of the new file %q", got, "Alpha")
}
if got := list.Length(); got != len(other) {
t.Fatalf("list length after the switch = %d, want %d", got, len(other))
}
// widget.List.Select returns without calling OnSelected when the row is
// already highlighted, so a silent Select(0) is what proves the highlight and
// the details pane are describing the same job.
reselected := false
inner := list.OnSelected
list.OnSelected = func(id widget.ListItemID) {
reselected = true
inner(id)
}
defer func() { list.OnSelected = inner }()
list.Select(0)
if reselected {
t.Error("row 0 was not the highlighted row after the switch, so the highlight and the details pane disagree")
}
}
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
// metadataRows list self-enforcing (F10): every caption it returns must
// measure no wider than captionColumnWidth's result for that same list, or a
+136
View File
@@ -0,0 +1,136 @@
package ui
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// newToolbar builds the per-job button row under the folder filter. Every
// handler works from the selected job — never from a row index — and ends in
// refresh, which is what re-reads the Service and redraws the row, the details
// pane, and the list highlight.
func (v *jobsView) newToolbar() fyne.CanvasObject {
return container.NewHBox(
v.newAddButton(),
v.newEditButton(),
v.newRunButton(),
v.newPauseButton(),
v.newDeleteButton(),
layout.NewSpacer(),
)
}
func (v *jobsView) newAddButton() *widget.Button {
return widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
blank := job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}
showJobDialog(v.w, "New job", blank, func(saved job) {
created, err := v.svc.CreateJob(saved)
if err != nil {
dialog.ShowError(err, v.w)
return
}
v.state.sync()
// The new job may have introduced a folder, so the options are rebuilt
// before the filter is pointed at it.
v.rebuildFolders()
v.state.selectByID(created.ID)
if target := filterValue(created.Folder); v.state.folder != allFolders && v.state.folder != target {
// The current filter would hide the job the user just created. Switch
// to its folder; SetSelected fires OnChanged, which applies the filter
// and refreshes.
v.folderSelect.SetSelected(target)
}
v.refresh()
})
})
}
func (v *jobsView) newEditButton() *widget.Button {
return widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
showJobDialog(v.w, "Edit job", current, func(saved job) {
// The ID comes from the job the dialog was opened on, so a list that
// changed underneath the open dialog cannot redirect the save.
saved.ID = current.ID
if err := v.svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, v.w)
return
}
v.state.sync()
// An edit can rename the job's folder, add a new one, or empty the last
// job out of an existing one.
v.rebuildFolders()
v.refresh()
})
})
}
func (v *jobsView) newRunButton() *widget.Button {
return widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
if err := v.svc.RunNow(current.ID); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
})
}
func (v *jobsView) newPauseButton() *widget.Button {
return widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
if err := v.svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
})
}
func (v *jobsView) newDeleteButton() *widget.Button {
return widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
deleted, ok := v.state.selected()
if !ok {
return
}
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
if err := v.svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, v.w)
return
}
// sync drops the deleted job's selection and falls back to the first row
// the filter still shows.
v.state.sync()
v.rebuildFolders()
if len(v.state.filtered) == 0 && v.state.folder != allFolders {
// The deleted job was the last one in its folder, and that folder is
// no longer an option. Fall back to "All" rather than leaving the user
// on an empty filter they did not choose.
v.folderSelect.SetSelected(allFolders)
}
v.refresh()
}, v.w)
})
}
+44 -35
View File
@@ -13,30 +13,23 @@ import (
"fyne.io/fyne/v2/theme"
)
const runRecordTimeLayout = "2006-01-02 15:04:05"
// The UI package aliases domain types to keep widget callbacks short. The actual
// durable model still lives in src/domain, so UI code does not define a second
// copy of the scheduler data.
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 := 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)
history, refreshHistory := newHistoryView(&events)
history, refreshHistory := newHistoryView(events)
recordStartup := func(duration time.Duration, windowShown bool) {
// Startup is recorded as an in-memory History event instead of being
// persisted into jobs.json. It is session diagnostics, not durable job
@@ -46,7 +39,7 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
if !windowShown {
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
}
events = append(events, newEvent(0, "Application", "Started", detail))
events.add(newEvent(0, "Application", "Started", detail))
refreshHistory()
}
@@ -63,40 +56,56 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
// the main thread in both cases, so the engine never mutates Fyne state off
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
recorded, isRecorded := ev.(app.RunRecorded)
errOccurred, isError := ev.(app.ErrorOccurred)
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
fyne.Do(func() {
if isRecorded {
events = append(events, recorded.Record)
r := recorded.Record
if r.State == "Failed" &&
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
// A type switch does not get compiler-enforced exhaustiveness (see
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
// intentionally fall through to the unconditional refresh() below
// without their own case, since a broad state re-read is all they need.
switch e := ev.(type) {
case app.RunRecorded:
events.add(e.Record)
if e.Record.State == "Failed" &&
(e.Record.Trigger == "Manual" || e.Record.Trigger == "Schedule") &&
svc.ShouldNotifyOnFailure() {
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: r.JobName + ": " + r.Detail,
timing := notificationTiming{
JobName: e.Record.JobName,
EmittedAt: time.Now(),
}
if finished, err := time.ParseInLocation(runRecordTimeLayout, e.Record.Time, time.Local); err == nil {
timing.RunFinished = finished
}
fyne.Do(func() {
timing.UIQueuedAt = time.Now()
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: e.Record.JobName + ": " + e.Record.Detail,
})
timing.AfterSendAt = time.Now()
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
fyne.LogError("Failed to write notification timing log", err)
}
})
}
}
if isError {
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
}
if isJobsLoaded {
case app.ErrorOccurred:
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
case app.JobsLoaded:
// Selecting an existing jobs file replaces the job list without a
// prompt, so History carries the receipt: how many jobs, from where.
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
events = append(events, newEvent(0, "Service", "Jobs loaded", detail))
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
events.add(newEvent(0, "Service", "Jobs loaded", detail))
}
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)
+69 -7
View File
@@ -3,12 +3,16 @@ package ui
import (
"path/filepath"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
)
// newTestStore builds a Store rooted in a temp directory. It is separate from
@@ -62,14 +66,42 @@ 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)
}
}
func TestMainViewBuilds(t *testing.T) {
// historyTable returns the History tab's table. It is the only widget.Table the
// main view builds, so the search does not need to know the tab order.
func historyTable(t *testing.T, content fyne.CanvasObject) *widget.Table {
t.Helper()
tabs, ok := content.(*container.AppTabs)
if !ok {
t.Fatal("main view is not the expected AppTabs container")
}
for _, item := range tabs.Items {
found := findFirst(item.Content, func(o fyne.CanvasObject) bool {
_, ok := o.(*widget.Table)
return ok
})
if found != nil {
return found.(*widget.Table)
}
}
t.Fatal("main view has no history table")
return nil
}
// TestMainViewRecordStartupAddsHistoryRow covers the second return value of
// newMainView. run.go calls it once per launch with a different windowShown
// flag depending on whether the app started into the tray, and that call is the
// only thing that puts the startup receipt into History — so both the wording
// and the fact that the table is redrawn are worth pinning. Building the full
// tab set and setting it as the window content is a side benefit: no other test
// assembles all three tabs together.
func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
@@ -79,10 +111,40 @@ func TestMainViewBuilds(t *testing.T) {
svc := newTestService(t)
defer svc.Stop()
content, recordStartup := newMainView(w, svc)
if content == nil {
t.Fatal("newMainView returned nil content")
}
content, recordStartup := newMainView(w, svc, &trayState{})
w.SetContent(content)
recordStartup(0, true)
table := historyTable(t, content)
if rows, _ := table.Length(); rows != 0 {
t.Fatalf("history rows before startup = %d, want 0", rows)
}
recordStartup(1500*time.Millisecond, true)
recordStartup(20*time.Millisecond, false)
rows, _ := table.Length()
if rows != 2 {
t.Fatalf("history rows after two startup records = %d, want 2", rows)
}
// Read the rows back through the table's own cell callbacks, which is what
// the redraw does; a value only in the events slice would not prove the
// table was refreshed with it.
cell := table.CreateCell()
cellText := func(row, col int) string {
table.UpdateCell(widget.TableCellID{Row: row, Col: col}, cell)
return cell.(*widget.Label).Text
}
if got := cellText(0, 2); got != "Application" {
t.Errorf("startup row job = %q, want %q", got, "Application")
}
if got := cellText(0, 3); got != "Started" {
t.Errorf("startup row state = %q, want %q", got, "Started")
}
if got := cellText(0, 4); got != "Window shown in 1.5s" {
t.Errorf("windowed startup detail = %q, want %q", got, "Window shown in 1.5s")
}
if got := cellText(1, 4); got != "Started in tray in 20ms" {
t.Errorf("tray startup detail = %q, want %q", got, "Started in tray in 20ms")
}
}
+72
View File
@@ -0,0 +1,72 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"time"
)
// 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
// Windows shows toasts via a separate PowerShell process after SendNotification
// returns.
type notificationTiming struct {
JobName string
RunFinished time.Time
EmittedAt time.Time
UIQueuedAt time.Time
AfterSendAt time.Time
}
func (t notificationTiming) formatLine() string {
return fmt.Sprintf(
"%s\tjob=%s\tms_after_run=%s\tms_fyne_do=%s\tms_send=%s\tms_app_total=%s\n",
t.AfterSendAt.Format(time.RFC3339Nano),
t.JobName,
msBetween(t.RunFinished, t.EmittedAt),
msBetween(t.EmittedAt, t.UIQueuedAt),
msBetween(t.UIQueuedAt, t.AfterSendAt),
msBetween(t.EmittedAt, t.AfterSendAt),
)
}
func msBetween(from, to time.Time) string {
if from.IsZero() || to.IsZero() || to.Before(from) {
return "-"
}
return fmt.Sprintf("%d", to.Sub(from).Milliseconds())
}
func appendNotificationTimingLog(logsDir string, timing notificationTiming) error {
if logsDir == "" {
return nil
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return err
}
path := filepath.Join(logsDir, notificationTimingLogName)
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
if info.Size() == 0 {
if _, err := file.WriteString("# GoSentry failure-notification timing (app side only; OS toast delay is not included)\n" +
"# columns: timestamp job ms_after_run ms_fyne_do ms_send ms_app_total\n"); err != nil {
return err
}
}
_, err = file.WriteString(timing.formatLine())
return err
}
+59
View File
@@ -0,0 +1,59 @@
package ui
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNotificationTimingFormatLine(t *testing.T) {
runFinished := time.Date(2026, 8, 5, 23, 0, 0, 0, time.Local)
emitted := runFinished.Add(15 * time.Millisecond)
uiQueued := emitted.Add(4 * time.Millisecond)
afterSend := uiQueued.Add(2 * time.Millisecond)
line := notificationTiming{
JobName: "Failure notification test",
RunFinished: runFinished,
EmittedAt: emitted,
UIQueuedAt: uiQueued,
AfterSendAt: afterSend,
}.formatLine()
if !strings.Contains(line, "job=Failure notification test") {
t.Fatalf("line = %q, want job name", line)
}
for _, want := range []string{"ms_after_run=15", "ms_fyne_do=4", "ms_send=2", "ms_app_total=6"} {
if !strings.Contains(line, want) {
t.Fatalf("line = %q, want substring %q", line, want)
}
}
}
func TestAppendNotificationTimingLogWritesHeaderAndRow(t *testing.T) {
dir := t.TempDir()
timing := notificationTiming{
JobName: "demo",
RunFinished: time.Now().Add(-10 * time.Millisecond),
EmittedAt: time.Now().Add(-5 * time.Millisecond),
UIQueuedAt: time.Now().Add(-2 * time.Millisecond),
AfterSendAt: time.Now(),
}
if err := appendNotificationTimingLog(dir, timing); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, notificationTimingLogName))
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.HasPrefix(text, "# GoSentry failure-notification timing") {
t.Fatalf("log = %q, want header", text)
}
if !strings.Contains(text, "job=demo") {
t.Fatalf("log = %q, want timing row", text)
}
}
+42 -13
View File
@@ -6,6 +6,7 @@ import (
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
@@ -16,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
@@ -29,7 +31,9 @@ const defaultWindowHeight = 660
// mainwindow.go split keeps lifecycle separate from view construction.
func Run(startInTray bool) {
started := time.Now()
instanceListener, primary := acquireSingleInstance(!startInTray)
keepInTray := storage.PeekKeepRunningInTray()
startHidden := resolveStartHidden(startInTray, keepInTray)
instanceListener, primary := acquireSingleInstance(!startHidden)
if !primary {
return
}
@@ -56,29 +60,32 @@ func Run(startInTray bool) {
}
w := a.NewWindow("GoSentry " + app.Version)
configureSystemTray(a, w)
prefs := a.Preferences()
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
w.Resize(fyne.NewSize(winW, winH))
setWindowsNotificationIcon()
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())))
a.Run()
return
}
config := svc.Config()
keepInTray = config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray)
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, svc.Store().Config.Theme)
content, recordStartup := newMainView(w, svc)
applyTheme(a, config.Theme)
content, recordStartup := newMainView(w, svc, tray)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
if startInTray {
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
// instead of forcing one timing definition onto two different UX flows.
recordStartup(time.Since(started), false)
a.Run()
svc.Stop()
return
}
// Show the window before recording startup time. Measuring earlier, during
@@ -88,4 +95,26 @@ func Run(startInTray bool) {
w.Show()
recordStartup(time.Since(started), true)
a.Run()
// a.Run() blocks until the tray's Quit item or a window close calls a.Quit().
// Stopping here — rather than not at all — cancels the run context so an
// in-flight run's os/exec call sees ctx.Done() instead of being orphaned, and
// stops the scheduler goroutine before the process exits.
svc.Stop()
}
// setWindowsNotificationIcon supplies App.Icon for Fyne desktop notifications
// without touching the window or taskbar icon. On Windows those come from the PE
// gosentry.ico resource, so run.go must not call SetIcon. Fyne's NewWindow ends
// with SetIcon(nil), which adopts App.Icon when it is already set — metadata
// must therefore be registered only after the window is created. The tray icon
// is set separately in tray.go via SetSystemTrayIcon.
func setWindowsNotificationIcon() {
if runtime.GOOS != "windows" {
return
}
fyneapp.SetMetadata(fyne.AppMetadata{
ID: appID,
Name: "GoSentry",
Icon: assets.Icon(),
})
}
+108 -59
View File
@@ -12,7 +12,7 @@ import (
"fyne.io/fyne/v2/widget"
)
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
const projectRepositoryURL = "https://github.com/mixeme/gosentry"
// settingsCaptions lists every settingsRow caption in the tab, in no
// particular order. settingsView measures this once with captionColumnWidth
@@ -26,8 +26,15 @@ var settingsCaptions = []string{
"GoSentry", "Go", "Fyne", "Repository",
}
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
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
// the live *storage.Store the Service owns (see app.Service.Config).
// paths never changes after construction of this view — AppDir and
// ConfigPath are fixed for the process — so it is read once, not refreshed.
saved := svc.Config()
paths := svc.Paths()
// updateSaveState compares the form to the saved config and enables Save only
// when something differs. It is defined below (once Save and every field
// exist) but declared here so the field change handlers can reference it.
@@ -36,33 +43,65 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// both the initial load and the Cancel/Defaults buttons below.
var loadFields func(domain.Config)
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
startOnLogin.SetChecked(saved.StartOnLogin)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(saved.KeepRunningInTray)
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() {
ok, message := svc.AutostartStatus()
if ok {
autostartStatus.SetText("OK: " + message)
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
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 {
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
return
}
trayRestartHint.SetText("")
}
startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
refreshAutostartStatus()
updateSaveState()
}
minimizeToTray.OnChanged = func(bool) {
refreshAutostartStatus()
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
updateSaveState()
}
refreshAutostartStatus()
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.SetChecked(saved.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() }
themeSelect := widget.NewSelect([]string{themeLabelDefault, themeLabelGoSentry}, nil)
themeSelect.SetSelected(themeLabel(store.Config.Theme))
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
themeSelect.SetSelected(themeLabel(saved.Theme))
// Preview the theme the moment it is picked so the choice is visible before
// saving; Save persists it. Reverting the selection reverts the preview, and
// closing without saving falls back to the stored theme on next launch.
@@ -74,20 +113,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
nil,
)
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
executionModeSelect.SetSelected(string(saved.ExecutionMode))
executionModeSelect.OnChanged = func(string) { updateSaveState() }
overlapPolicySelect := widget.NewSelect(
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
defaultTimeout := widget.NewEntry()
defaultTimeout.SetPlaceHolder("0 = no timeout")
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
defaultTimeout.OnChanged = func(string) { updateSaveState() }
jobsFile := widget.NewEntry()
jobsFile.SetText(store.Config.JobsFile)
jobsFile.SetText(saved.JobsFile)
jobsFile.OnChanged = func(string) { updateSaveState() }
// The picker only offers existing files; a jobs file that does not exist yet
// is entered by typing its path, which Save then creates.
@@ -95,7 +134,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
chooseJSONFile(w, jobsFile)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDir.SetText(saved.LogsDir)
logsDir.OnChanged = func(string) { updateSaveState() }
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
@@ -105,13 +144,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// manager. It reveals whatever the field currently holds, so an edit can be
// checked before Save.
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
openFolder(w, settingsFolderPath(store.Paths.AppDir, logsDir.Text))
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogFiles.SetPlaceHolder("0 = unlimited")
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
maxLogFiles.OnChanged = func(string) { updateSaveState() }
maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
// Autostart status sits on its own row beneath the checkbox (rather than
// beside it) so the Application section fits within a half-width column.
@@ -120,33 +161,22 @@ 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 a positive number")
return
}
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
if err != nil || days <= 0 {
settingsStatus.SetText("Max log age days must be a positive number")
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
// validates it, persists config and jobs to the (possibly new) directory,
// and runs log cleanup so tightened retention limits take effect at once.
config := store.Config
config := saved
config.JobsFile = strings.TrimSpace(jobsFile.Text)
config.LogsDir = strings.TrimSpace(logsDir.Text)
config.MaxLogFiles = files
@@ -158,16 +188,28 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout
config.Theme = themeFromLabel(themeSelect.Selected)
previousKeepInTray := saved.KeepRunningInTray
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
// different directory), so pick up the fresh copy rather than assuming
// config is exactly what landed.
saved = svc.Config()
paths = svc.Paths()
if err := svc.ApplyAutostart(); err != nil {
refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
return
}
refreshAutostartStatus()
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
if previousKeepInTray != config.KeepRunningInTray {
trayRestartHint.SetText(trayRestartHintText)
} else {
refreshTrayRestartHint(false)
}
settingsStatus.SetText("Saved")
// The form now matches the persisted config, so disable Save again.
updateSaveState()
@@ -178,7 +220,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// fields compare against their canonical string form; any unparsable text
// counts as a change so the user can click Save and see the validation error.
updateSaveState = func() {
c := store.Config
c := saved
changed := startOnLogin.Checked != c.StartOnLogin ||
minimizeToTray.Checked != c.KeepRunningInTray ||
notifications.Checked != c.NotifyOnFailure ||
@@ -215,16 +257,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
logsDir.SetText(c.LogsDir)
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
if startOnLogin.Checked != store.Config.StartOnLogin {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
settingsStatus.SetText("")
updateSaveState()
}
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
loadFields(store.Config)
loadFields(saved)
})
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
loadFields(domain.DefaultConfig())
@@ -234,12 +277,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
startOnLogin: startOnLogin,
autostartStatus: autostartStatus,
minimizeToTray: minimizeToTray,
trayRestartHint: trayRestartHint,
notifications: notifications,
themeSelect: themeSelect,
executionModeSelect: executionModeSelect,
overlapPolicySelect: overlapPolicySelect,
defaultTimeout: defaultTimeout,
configPath: store.Paths.ConfigPath,
configPath: paths.ConfigPath,
jobsFile: jobsFile,
jobsFileBrowse: jobsFileBrowse,
logsDir: logsDir,
@@ -254,24 +298,29 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
})
}
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
return startOnLogin.Checked != saved.StartOnLogin ||
minimizeToTray.Checked != saved.KeepRunningInTray
}
// Theme dropdown labels. These are the human-facing captions; themeLabel and
// themeFromLabel translate between them and the stored domain.Theme values so the
// select never leaks the on-disk "default"/"gosentry" strings to the user.
// select never leaks the on-disk "system"/"gosentry" strings to the user.
const (
themeLabelDefault = "Default"
themeLabelSystem = "System"
themeLabelGoSentry = "GoSentry"
)
func themeLabel(choice domain.Theme) string {
if choice == domain.ThemeGoSentry {
return themeLabelGoSentry
if choice == domain.ThemeSystem {
return themeLabelSystem
}
return themeLabelDefault
return themeLabelGoSentry
}
func themeFromLabel(label string) domain.Theme {
if label == themeLabelGoSentry {
return domain.ThemeGoSentry
}
return domain.ThemeDefault
return domain.ThemeSystem
}
+2
View File
@@ -20,6 +20,7 @@ type settingsFormFields struct {
startOnLogin *widget.Check
autostartStatus *widget.Label
minimizeToTray *widget.Check
trayRestartHint *widget.Label
notifications *widget.Check
themeSelect *widget.Select
executionModeSelect *widget.Select
@@ -59,6 +60,7 @@ func newSettingsLayout(f settingsFormFields) fyne.CanvasObject {
// empty caption, so the Application section fits in a half-width column.
settingsRow(capW, "", f.autostartStatus),
settingsRow(capW, "Tray", f.minimizeToTray),
settingsRow(capW, "", f.trayRestartHint),
settingsRow(capW, "Notifications", f.notifications),
// Theme is the one row here whose value is not text: the Select paints
// a box out to the row's edge, so the section's overlap would leave it
+6 -2
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,6 +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() {
tray.hidden = false
w.Show()
w.RequestFocus()
})
+6 -6
View File
@@ -103,14 +103,14 @@ func (t gosentryTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.
func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Anything other
// than the explicit GoSentry choice (including the empty/legacy value) keeps
// Fyne's built-in theme.
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Only the
// explicit system choice keeps Fyne's built-in theme; everything else
// (including the empty/legacy value) uses the branded GoSentry theme.
func themeFor(choice domain.Theme) fyne.Theme {
if choice == domain.ThemeGoSentry {
return newGoSentryTheme()
if choice == domain.ThemeSystem {
return theme.DefaultTheme()
}
return theme.DefaultTheme()
return newGoSentryTheme()
}
// applyTheme installs the theme for the given choice on the running app. Fyne
+16 -16
View File
@@ -54,32 +54,32 @@ func TestGoSentryThemeDelegatesUnbrandedColors(t *testing.T) {
}
}
// themeFor maps the stored choice to the right theme: the GoSentry choice yields
// the branded teal primary; every other value (including the empty legacy value)
// yields the default theme, whose primary is not the brand teal.
// themeFor maps the stored choice to the right theme: the GoSentry choice and the
// empty legacy value yield the branded teal primary; only the explicit system
// choice yields Fyne's built-in theme.
func TestThemeForChoice(t *testing.T) {
gosentry := themeFor(domain.ThemeGoSentry)
if got := gosentry.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
t.Errorf("themeFor(gosentry) primary = %v, want brand teal %v", got, brandTeal)
}
for _, choice := range []domain.Theme{domain.ThemeDefault, ""} {
def := themeFor(choice)
if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
t.Errorf("themeFor(%q) should not use the brand teal primary", choice)
for _, choice := range []domain.Theme{domain.ThemeGoSentry, ""} {
branded := themeFor(choice)
if got := branded.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
t.Errorf("themeFor(%q) primary = %v, want brand teal %v", choice, got, brandTeal)
}
}
sys := themeFor(domain.ThemeSystem)
if got := sys.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
t.Errorf("themeFor(system) should not use the brand teal primary")
}
}
// The dropdown label helpers must round-trip, and the empty/legacy value must map
// to the Default label so the select never shows a blank option.
// to the GoSentry label so the select never shows a blank option.
func TestThemeLabelRoundTrip(t *testing.T) {
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
t.Errorf("round-trip gosentry = %q", got)
}
if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault {
t.Errorf("round-trip default = %q", got)
if got := themeFromLabel(themeLabel(domain.ThemeSystem)); got != domain.ThemeSystem {
t.Errorf("round-trip system = %q", got)
}
if got := themeLabel(""); got != themeLabelDefault {
t.Errorf("empty theme label = %q, want %q", got, themeLabelDefault)
if got := themeLabel(""); got != themeLabelGoSentry {
t.Errorf("empty theme label = %q, want %q", got, themeLabelGoSentry)
}
}
+54 -22
View File
@@ -4,12 +4,49 @@ import (
"runtime"
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
fynedesktop "fyne.io/fyne/v2/driver/desktop"
)
func configureSystemTray(a fyne.App, w fyne.Window) {
// 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."
func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
}
// 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
}
t.setWindowCloseBehavior(w, keepInTray)
if !keepInTray && revealIfHidden && t.hidden {
t.hidden = false
w.Show()
w.RequestFocus()
}
}
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
@@ -34,26 +71,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
// localized label — which our literal "Quit" does not. Setting IsQuit makes
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
// Window size persistence is frozen: w.Canvas().Size() returns the maximized
// dimensions when the window is maximized, so saving here would corrupt the
// stored size. Needs cross-platform maximized-state detection (IsZoomed /
// _NET_WM_STATE / NSWindow.isZoomed) before it can be re-enabled safely.
// See ROADMAP.md — "Window size — skip saving when maximized".
//
// saveWindowSize := func() {
// size := w.Canvas().Size()
// prefs := a.Preferences()
// prefs.SetFloat("window.width", float64(size.Width))
// prefs.SetFloat("window.height", float64(size.Height))
// }
quit := fyne.NewMenuItem("Quit", func() {
// saveWindowSize()
a.Quit()
})
quit.IsQuit = true
menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() {
t.hidden = false
w.Show()
w.RequestFocus()
}),
@@ -62,11 +86,19 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
)
desk.SetSystemTrayMenu(menu)
desk.SetSystemTrayWindow(w)
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.
// saveWindowSize()
w.Hide()
})
}
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.
t.hidden = true
w.Hide()
})
return
}
t.hidden = false
w.SetCloseIntercept(nil)
}
+14
View File
@@ -0,0 +1,14 @@
package ui
import "testing"
func TestResolveStartHiddenUsesDomainHelper(t *testing.T) {
// resolveStartHidden is the UI alias used at startup; it must stay aligned
// with domain.ResolveStartHidden so run.go and tests share one definition.
if got := resolveStartHidden(true, false); got {
t.Fatal("expected hidden start to require both CLI flag and keepInTray")
}
if !resolveStartHidden(true, true) {
t.Fatal("expected hidden start when both flags are set")
}
}