Compare commits
2 Commits
bd7ebde68e
...
da8fe10365
| Author | SHA1 | Date | |
|---|---|---|---|
| da8fe10365 | |||
| 18da021526 |
@@ -181,7 +181,7 @@ Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
|
|||||||
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
|
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
|
||||||
4. Use **Run now** for a one-off manual run without waiting for the schedule.
|
4. Use **Run now** for a one-off manual run without waiting for the schedule.
|
||||||
5. Use **Pause** on a single job to suspend it without deleting it.
|
5. Use **Pause** on a single job to suspend it without deleting it.
|
||||||
6. Use **Pause all** as a global stop switch for all scheduled runs.
|
6. Use **Disable auto** as a global stop switch for all scheduled runs.
|
||||||
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
|
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
|
||||||
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
||||||
|
|
||||||
@@ -241,8 +241,9 @@ sets one.
|
|||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
|
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
|
||||||
notification whenever a scheduled or manual run exits with a non-zero exit code.
|
notification whenever a scheduled or manual run ends in the `Failed` state —
|
||||||
The notification shows the job name and the exit code.
|
a non-zero exit code, a timeout, or a process that failed to start.
|
||||||
|
The notification shows the job name and the failure detail.
|
||||||
|
|
||||||
## Autostart
|
## Autostart
|
||||||
|
|
||||||
|
|||||||
@@ -237,8 +237,9 @@ applies to them — and so measure launch latency only.
|
|||||||
| `RunCount` | total runs recorded |
|
| `RunCount` | total runs recorded |
|
||||||
| `FailCount` | runs that exited non-zero |
|
| `FailCount` | runs that exited non-zero |
|
||||||
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
|
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
|
||||||
| `AvgDurationMS` | mean over all runs with a recorded duration |
|
| `AvgDurationMS` | mean over all runs with a recorded duration, computed as `DurationSumMS / TimedRunCount` on every update rather than folded incrementally, so it never disagrees with the exact sum/count average `runner.aggregateLogStats` computes when seeding from logs |
|
||||||
| `MaxDurationMS` | longest recorded run |
|
| `MaxDurationMS` | longest recorded run |
|
||||||
|
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
|
||||||
|
|
||||||
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
|
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
|
||||||
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
||||||
|
|||||||
+11
-3
@@ -77,9 +77,17 @@ the app icon (experimental).**
|
|||||||
|
|
||||||
**Internal:**
|
**Internal:**
|
||||||
|
|
||||||
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
- App-side failure-notification timing is appended to `logs/notify-timing.tsv`
|
||||||
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
|
for diagnosing toast delay (OS latency excluded). The `.tsv` extension keeps
|
||||||
measures the PowerShell baseline on Windows.
|
the diagnostic file out of `CleanupLogs`, which manages only `.log` files, so
|
||||||
|
it is neither deleted by age nor counted against **Max log files**. The append
|
||||||
|
runs off the UI thread. `scripts/measure-windows-toast.ps1` measures the
|
||||||
|
PowerShell baseline on Windows.
|
||||||
|
- `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
|
- 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
|
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
|
post-run log cleanup, or the startup log scan used to make a UI refresh wait
|
||||||
|
|||||||
@@ -1,793 +0,0 @@
|
|||||||
# Whole-project review — action plan
|
|
||||||
|
|
||||||
Working document for the findings of the 2026-08-05 whole-project review. It is
|
|
||||||
not part of the permanent doc set: delete it once every item below is either
|
|
||||||
done or moved to [ROADMAP.md](ROADMAP.md), the way `TEST_REVIEW_PLAN.md` was
|
|
||||||
retired.
|
|
||||||
|
|
||||||
The rules the findings are judged against live in [STANDARDS.md](STANDARDS.md)
|
|
||||||
and [ARCHITECTURE.md](ARCHITECTURE.md). Anything listed under "Intentional
|
|
||||||
behavior" in STANDARDS is not reported as a bug; where this review disagrees
|
|
||||||
with such an entry it says so explicitly as a **challenge**.
|
|
||||||
|
|
||||||
## Baseline the review started from
|
|
||||||
|
|
||||||
Measured on the 1.0.2 tree (`c8a4d31`), MSYS2 UCRT64 / CGO on:
|
|
||||||
|
|
||||||
- `go vet ./...` — clean.
|
|
||||||
- `go test -race ./...` — all packages pass. `src/ui` alone takes **229 s**;
|
|
||||||
everything else finishes in under 8 s.
|
|
||||||
- Engine coverage, merged profile over `domain`, `storage`, `runner`,
|
|
||||||
`scheduler`, `app`: **84.0%** (TESTS.md records 84.4% at the 2026-08-04
|
|
||||||
review). The 0.4 pp dip is *not* item 4.1 — it is new 1.0.2 code that arrived
|
|
||||||
untested: `storage.PeekKeepRunningInTray` sits at 0%. It is a startup entry
|
|
||||||
point like `OpenStore` and `ResolvePaths`, so if it is meant to stay
|
|
||||||
uncovered it belongs in TESTS.md's "Functions deliberately at 0%" list, which
|
|
||||||
currently does not name it.
|
|
||||||
- 171 test functions in the tree. TESTS.md names 171 as well, but the sets do
|
|
||||||
not match: two of the names it documents no longer exist, and two tests that
|
|
||||||
do exist are undocumented (items 4.1, 4.2).
|
|
||||||
- 77 Go files, ~10 500 lines including tests. Two direct dependencies.
|
|
||||||
|
|
||||||
Overall finding: **the project is in good health.** The engine layering
|
|
||||||
(`domain` → `storage`/`runner`/`scheduler` → `app` → `ui`) holds, the locking
|
|
||||||
contract on `Service.mu` is stated and obeyed, and the UI layout code — usually
|
|
||||||
the first thing to rot in a desktop app — is the strongest part of the codebase:
|
|
||||||
sizes are measured from the theme, the helpers are named, and the geometry is
|
|
||||||
pinned by tests that re-run under a scaled theme. The documentation set is
|
|
||||||
unusually complete and, with the exceptions in §4, accurate.
|
|
||||||
|
|
||||||
The findings below are therefore mostly about **the paths that only show up
|
|
||||||
after the app has been running for a while** (§3.1, §6.3), **durability of the
|
|
||||||
JSON files** (§6.2), and **one confirmed Windows quoting bug** (§6.1).
|
|
||||||
|
|
||||||
Severity follows the whole-project review convention: *medium* means it gets a
|
|
||||||
regression test with the fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architecture and project structure
|
|
||||||
|
|
||||||
### 1.1 `Service.Store()` is the hole in "the Service is the sole owner" — medium
|
|
||||||
|
|
||||||
[service.go:170](../src/app/service.go) hands callers the raw `*storage.Store`.
|
|
||||||
Its own doc calls the surface transitional ("later phases narrow this"); the
|
|
||||||
phase never came. Eight UI sites read Service-owned state straight through it:
|
|
||||||
|
|
||||||
| Site | Reads |
|
|
||||||
|---|---|
|
|
||||||
| [jobs_view.go:60,61,64,66,79](../src/ui/jobs_view.go) | `Config.Paused`, `Config.JobListView`, `Config.OverlapPolicy`, `Config.DefaultTimeoutSeconds` |
|
|
||||||
| [settings_view.go:30](../src/ui/settings_view.go) | the whole `Config`, held as a live pointer for the session |
|
|
||||||
| [run.go:73,78](../src/ui/run.go) | `Config.KeepRunningInTray`, `Config.Theme` |
|
|
||||||
| [mainwindow.go:92](../src/ui/mainwindow.go) | `Paths.LogsDir`, from inside the notification path |
|
|
||||||
|
|
||||||
This contradicts ARCHITECTURE ("the UI reads it through typed events, never
|
|
||||||
through shared mutable state") and STANDARDS. It is not a live data race
|
|
||||||
**today**, but only because of an invariant nothing writes down and nothing
|
|
||||||
enforces: every writer of `store.Config` (`UpdateSettings`, `SetGlobalPause`,
|
|
||||||
`SetJobListView`) happens to be reached from the Fyne main thread, so the
|
|
||||||
unlocked UI reads are serialised with them by accident. One background writer —
|
|
||||||
say, a future auto-reload of `jobs.json`, or moving log cleanup off the UI
|
|
||||||
thread — turns all eight into races that `-race` will not catch, because no test
|
|
||||||
drives them concurrently.
|
|
||||||
|
|
||||||
Fix: give the Service typed accessors that copy under `mu` (`Config()`,
|
|
||||||
`LogsDir()`), convert the eight call sites, and either unexport `Store()` or
|
|
||||||
reduce it to what the tests actually need.
|
|
||||||
|
|
||||||
### 1.2 `SchedulerStateChanged` is emitted and never consumed — low
|
|
||||||
|
|
||||||
[events.go:38](../src/app/events.go) documents it as "The UI uses it to update
|
|
||||||
the pause/resume control and status text." No observer handles it: the single
|
|
||||||
listener in [mainwindow.go:67](../src/ui/mainwindow.go) type-asserts only
|
|
||||||
`RunRecorded`, `ErrorOccurred`, and `JobsLoaded`. The Jobs toolbar keeps its own
|
|
||||||
`schedulerPaused` copy and relabels the button inside its own tap handler
|
|
||||||
([jobs_view.go:271](../src/ui/jobs_view.go)).
|
|
||||||
|
|
||||||
It works because the tap handler is the only thing that can pause today. That is
|
|
||||||
exactly the coupling the event bus exists to remove. Either consume the event
|
|
||||||
and delete the local mirror, or delete the event and drop the claim.
|
|
||||||
|
|
||||||
### 1.3 The "exhaustive type-switch" the doc promises does not exist — low
|
|
||||||
|
|
||||||
The same comment block says the sealed `Event` interface means "a UI listener
|
|
||||||
can exhaustively type-switch over them and the compiler will flag a new event
|
|
||||||
type that a switch forgot to handle." Go has no exhaustiveness check on type
|
|
||||||
switches, and the one listener does not even use a switch — it uses three
|
|
||||||
independent assertions. The comment claims a safety property that is not there,
|
|
||||||
which is how 1.2 went unnoticed. Reword it to say what sealing actually buys
|
|
||||||
(observers cannot be handed an event type from outside the package).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Complexity against the size of the project
|
|
||||||
|
|
||||||
Nothing here is over-abstracted: the `Clock` interface, the `runJob` seam, and
|
|
||||||
the `autostart.Manager` interface each have a real test seam or a real second
|
|
||||||
implementation. The findings run the other way — code that is still there after
|
|
||||||
its reason left.
|
|
||||||
|
|
||||||
### 2.1 Two full `jobs.json` rewrites per run that cannot change the file — medium
|
|
||||||
|
|
||||||
[startRunLocked](../src/app/run.go) calls `s.store.SaveJobs(s.jobs)` on every run
|
|
||||||
start and [executeRun](../src/app/run.go) calls it again on every run finish.
|
|
||||||
Neither function assigns to a single `domain.Job` field: everything they touch
|
|
||||||
lives on `JobRuntime`, which is explicitly never persisted
|
|
||||||
([runtime.go:5](../src/domain/runtime.go)). Both calls therefore re-serialise
|
|
||||||
and rewrite the identical bytes. `SetGlobalPause`
|
|
||||||
([operations.go:182](../src/app/operations.go)) does the same — its durable
|
|
||||||
change is `Config.Paused`, saved separately by `SaveConfig`.
|
|
||||||
|
|
||||||
The cost is not only I/O. `startRunLocked` carries a five-line rollback block
|
|
||||||
and a regression test (`TestStartRunLockedRollbackOnSaveFailure`) guarding a
|
|
||||||
write that can never change the file's content, and the write happens under
|
|
||||||
`Service.mu` (see 3.2). Removing the three calls removes the I/O, the rollback,
|
|
||||||
and the failure mode at once.
|
|
||||||
|
|
||||||
Care needed: the review found no durable field written on these paths, but this
|
|
||||||
should be re-verified against the `domain.Job` definition when the change is
|
|
||||||
made, and `TestStartRunLockedRollbackOnSaveFailure` retired deliberately rather
|
|
||||||
than left failing.
|
|
||||||
|
|
||||||
### 2.2 `runner.logArguments` is an alias of `runner.LogArguments` — low
|
|
||||||
|
|
||||||
[invocation.go:68](../src/runner/invocation.go) —
|
|
||||||
`func logArguments(a string) string { return LogArguments(a) }`. A leftover from
|
|
||||||
exporting the function. Four call sites; inline them and delete it.
|
|
||||||
|
|
||||||
### 2.3 `collectActivity` always returns an empty slice at startup — low
|
|
||||||
|
|
||||||
[mainwindow.go:30-37](../src/ui/mainwindow.go) builds an `initialRuntimes` map
|
|
||||||
purely to feed [collectActivity](../src/ui/history_view.go), which merges
|
|
||||||
`JobRuntime.Logs` across jobs. History is session-only by design, so at
|
|
||||||
construction time every `Logs` slice is empty and the result is always `nil`.
|
|
||||||
The function's own comment says it is kept "for future history loading from log
|
|
||||||
metadata" — a feature that is not on the ROADMAP.
|
|
||||||
|
|
||||||
Either delete the twelve lines, or record the placeholder in STANDARDS so the
|
|
||||||
next reviewer does not re-report it. Its two unit tests are fine either way —
|
|
||||||
they test the merge, not the caller.
|
|
||||||
|
|
||||||
### 2.4 The ROADMAP size table is stale — info
|
|
||||||
|
|
||||||
[ROADMAP.md](ROADMAP.md) lists the files over the ~250-line guideline as of
|
|
||||||
1.0.0. Measured today:
|
|
||||||
|
|
||||||
| File | ROADMAP | Now |
|
|
||||||
|---|---|---|
|
|
||||||
| `src/app/operations.go` | 490 | 490 |
|
|
||||||
| `src/ui/jobs_view.go` | 355 | 361 |
|
|
||||||
| `src/ui/settings_view.go` | 277 | **304** |
|
|
||||||
| `src/storage/store.go` | 265 | **299** |
|
|
||||||
| `src/app/run.go` | 287 | 287 |
|
|
||||||
| `src/ui/history_view.go` | 282 | 282 |
|
|
||||||
|
|
||||||
Refresh the numbers when the split item is picked up; the trend is the point,
|
|
||||||
not the individual figures.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Code quality
|
|
||||||
|
|
||||||
### 3.1 History grows without bound, and every run pays for it — medium
|
|
||||||
|
|
||||||
This is the most consequential finding in the review, because it only appears in
|
|
||||||
the mode the app is designed to run in: left in the tray for days.
|
|
||||||
|
|
||||||
`events` in [mainwindow.go:73](../src/ui/mainwindow.go) is appended to on every
|
|
||||||
`RunRecorded` and never trimmed. Each entry is a full `domain.RunRecord`,
|
|
||||||
including `Output` — the complete captured stdout and stderr of the run. Then,
|
|
||||||
on every single event, `refresh()` runs:
|
|
||||||
|
|
||||||
- `resort()` — copies the whole slice and sorts it
|
|
||||||
([history_view.go:181](../src/ui/history_view.go));
|
|
||||||
- `setColumnWidths()` → `historyColumnWidths(rows)` — builds three
|
|
||||||
slices of length *n* and calls `fyne.MeasureText` once per non-empty value in
|
|
||||||
each of the Job, Detail, and Log columns
|
|
||||||
([history_view.go:104](../src/ui/history_view.go)).
|
|
||||||
|
|
||||||
So the per-run cost is O(*n* log *n*) sorting plus up to 3*n* text measurements
|
|
||||||
on the UI thread, with *n* growing forever. One job on `@every 10s` produces
|
|
||||||
~8 600 records a day. `JobRuntime.Logs` is capped at 50 by `maxJobLogs`; the
|
|
||||||
History slice — the one that actually accumulates — is not capped at all.
|
|
||||||
|
|
||||||
Fix in two parts: cap the History slice (a ring buffer, or a `maxHistoryRows`
|
|
||||||
mirroring `maxJobLogs`), and stop rescanning every row for column widths on
|
|
||||||
every event — widths only ever grow, so fold the new record into the current
|
|
||||||
maxima instead of recomputing from scratch.
|
|
||||||
|
|
||||||
This is the one finding worth a measurement before and after, since STANDARDS
|
|
||||||
already treats measured geometry as the standard of proof.
|
|
||||||
|
|
||||||
### 3.2 Blocking file I/O under `Service.mu` — medium
|
|
||||||
|
|
||||||
`Service.mu` is the lock the Fyne main thread takes on every `Jobs()` and
|
|
||||||
`Runtime()` call — that is, on every UI refresh. Three things do file I/O while
|
|
||||||
holding it:
|
|
||||||
|
|
||||||
- [executeRun](../src/app/run.go) calls `runner.CleanupLogs` — a directory scan
|
|
||||||
plus up to `MaxLogFiles` unlinks — under `mu`, after every run.
|
|
||||||
- Every `SaveJobs` / `SaveConfig` is a full JSON marshal and write under `mu`.
|
|
||||||
- [adoptJobsLocked](../src/app/service.go) calls `runner.SeedStats` under `mu`,
|
|
||||||
reached from `UpdateSettings` on the UI thread.
|
|
||||||
|
|
||||||
None of it needs the lock: cleanup takes only the values already snapshotted
|
|
||||||
into `runEnv`, and seeding only needs the job list. Move them outside the
|
|
||||||
critical section, or snapshot and run them after `mu.Unlock()` the way the event
|
|
||||||
emission already does.
|
|
||||||
|
|
||||||
`SeedStats` also opens every log file **twice** — once in `readLogJobID` and
|
|
||||||
again in `readLogHeader` ([seed.go:59,98](../src/runner/seed.go)) — and the
|
|
||||||
first pass is not bounded by `maxFiles`, so it touches every `.log` in the
|
|
||||||
directory. One pass returning `(jobID, state, duration)` halves the syscalls.
|
|
||||||
|
|
||||||
### 3.3 `StartOnly` leaks a goroutine per run and mis-owns the process — medium
|
|
||||||
|
|
||||||
[runner.go:39](../src/runner/runner.go) builds the fire-and-forget invocation
|
|
||||||
with `jobInvocation(ctx, …)`, which uses `exec.CommandContext`. After `Start()`,
|
|
||||||
os/exec spawns a watcher goroutine that blocks until either `Wait()` returns or
|
|
||||||
the context is done. `StartOnly` never calls `Wait` — that is the whole point —
|
|
||||||
so the goroutine lives until the app exits, one per StartOnly run, and then
|
|
||||||
calls `Kill` on a process whose handle `startJobOnly` already `Release`d.
|
|
||||||
|
|
||||||
The kill is harmless in practice (a released handle makes it fail), but the
|
|
||||||
leak is real and the ownership is the wrong shape: a job the runner explicitly
|
|
||||||
stops waiting for should not be tied to the app's lifecycle context at all. Use
|
|
||||||
`exec.Command` (or `context.Background()`) for the StartOnly branch and say in
|
|
||||||
STANDARDS whether a started process is expected to outlive GoSentry.
|
|
||||||
|
|
||||||
### 3.4 `InstallDesktopIcon` swallows its error — low
|
|
||||||
|
|
||||||
[platform.go:11](../src/app/platform.go) —
|
|
||||||
`if iconPath, err := desktop.InstallDesktopIntegration(…); err == nil { … }`.
|
|
||||||
The error is discarded with no dialog, no History event, and no log line. That
|
|
||||||
is the silent `return` STANDARDS forbids. On Linux the visible symptom is a
|
|
||||||
generic dock icon with no explanation. Emit `ErrorOccurred`.
|
|
||||||
|
|
||||||
### 3.5 `RunDue` keeps only the last start error — low
|
|
||||||
|
|
||||||
[run.go:92](../src/app/run.go) — `startErr = err; continue`. If two jobs fail to
|
|
||||||
start on the same tick, the user sees one message. Join them (`errors.Join`) or
|
|
||||||
emit one event per failure.
|
|
||||||
|
|
||||||
### 3.6 Settings re-implements `validateConfig` — low
|
|
||||||
|
|
||||||
[settings_view.go:136-158](../src/ui/settings_view.go) validates max log files,
|
|
||||||
max log age, jobs file, logs dir, and default timeout with its own messages,
|
|
||||||
before `UpdateSettings` validates the same five with different messages
|
|
||||||
([operations.go:456](../src/app/operations.go)). The UI genuinely needs the
|
|
||||||
`strconv` parse; it does not need a second copy of the rules. Parse in the UI,
|
|
||||||
validate in the Service, and show what the Service returns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Documentation and comments
|
|
||||||
|
|
||||||
The doc set is accurate about design and rationale. What has drifted is the
|
|
||||||
inventory.
|
|
||||||
|
|
||||||
### 4.1 Two documented tests were silently deleted — medium
|
|
||||||
|
|
||||||
[TESTS.md](TESTS.md) lists `TestJobListViewIsCompact` and
|
|
||||||
`TestDefaultConfigUsesDetailedJobList` under `src/domain/config_test.go`.
|
|
||||||
Neither exists. Commit `5b0e6fe` ("Wire KeepRunningInTray to runtime …")
|
|
||||||
**rewrote** that file to hold `TestAutostartArguments` and
|
|
||||||
`TestResolveStartHidden` instead of appending them, and the two older tests went
|
|
||||||
with it.
|
|
||||||
|
|
||||||
**This was not the test-suite review's doing, and it was not a decision.** The
|
|
||||||
2026-08-04 review deleted exactly three tests — `TestCleanupLogsKeepsFilesWithinAgeLimit`,
|
|
||||||
`TestRunDueEmptyOverlapInheritsGlobal`, and `TestSameWindowsPathHandlesSpaces` —
|
|
||||||
each after measuring byte-identical coverage against a survivor whose assertions
|
|
||||||
were a superset, and each recorded in `TEST_REVIEW_PLAN.md` and in the CHANGELOG.
|
|
||||||
Its deletion commit `2ef18e7` never opened `config_test.go`; only `29ce94c`
|
|
||||||
(which created the two tests) and `5b0e6fe` ever touched that file.
|
|
||||||
|
|
||||||
What settles it is what `5b0e6fe` did to the documentation: it **added** the two
|
|
||||||
new test rows to TESTS.md while **leaving the two old rows in place**, i.e. it
|
|
||||||
documented the file as holding all four. The doc moved in the opposite direction
|
|
||||||
from the code. A deliberate removal looks like `2ef18e7`, which took its three
|
|
||||||
rows out of TESTS.md in the same commit. Nothing in the commit message, the
|
|
||||||
1.0.2 CHANGELOG, or STANDARDS mentions the loss.
|
|
||||||
|
|
||||||
Accidental, however, does not mean both are worth having back. Only one was
|
|
||||||
pulling weight:
|
|
||||||
|
|
||||||
- **`TestJobListViewIsCompact` — restore it.** Its unique assertions are that
|
|
||||||
`""` and a differently-cased `"Compact"` both read as detailed. Neither holds
|
|
||||||
anywhere else now: `TestSetJobListViewNormalizesUnknownValue` (`app`) covers
|
|
||||||
only the unrecognised-value path through `SetJobListView`. The empty case is
|
|
||||||
live rather than theoretical — `loadOrCreateConfig` does **not** normalize
|
|
||||||
`job_list_view` the way it normalizes `theme`, so a config written before the
|
|
||||||
field existed reaches `IsCompact()` empty and depends on exactly this
|
|
||||||
behaviour. STANDARDS §Config file compatibility also requires it by name:
|
|
||||||
"Each of the three gets a test: the default in `storage`, the normalization
|
|
||||||
in `domain`, and a round-trip through the real config file in `app`." The
|
|
||||||
`domain` one is the one that disappeared, so a rule STANDARDS calls mandatory
|
|
||||||
is currently unenforced.
|
|
||||||
- **`TestDefaultConfigUsesDetailedJobList` — do not restore it; take its row
|
|
||||||
out of TESTS.md instead.** `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun`
|
|
||||||
(`storage`) already asserts `got.JobListView == domain.JobListViewDetailed`,
|
|
||||||
through the real load path, which makes it a strict superset — and STANDARDS
|
|
||||||
puts the default test in `storage`, not `domain`. Under TESTS.md principle 9
|
|
||||||
this is a legitimate deletion; it simply was never made deliberately.
|
|
||||||
|
|
||||||
Neither loss moved the number: `IsCompact` and `DefaultConfig` both measure
|
|
||||||
100% today, exercised through their callers. What was lost is an assertion, not
|
|
||||||
statement coverage — which is the exact case TESTS.md principle 9 exists to
|
|
||||||
name ("Identical coverage alone is *not* grounds for deletion").
|
|
||||||
|
|
||||||
The surviving test is recoverable verbatim from
|
|
||||||
`git show 5b0e6fe^:src/domain/config_test.go`.
|
|
||||||
|
|
||||||
### 4.2 `src/ui/notify_timing_test.go` is undocumented — low
|
|
||||||
|
|
||||||
`TestNotificationTimingFormatLine` and
|
|
||||||
`TestAppendNotificationTimingLogWritesHeaderAndRow` were added in 1.0.2 with no
|
|
||||||
TESTS.md entry. Add the file's table.
|
|
||||||
|
|
||||||
### 4.3 The window-size comment describes a feature that is frozen — low
|
|
||||||
|
|
||||||
[run.go:19](../src/ui/run.go): "later launches restore the last size from
|
|
||||||
preferences." Nothing ever writes `window.width` / `window.height` — ROADMAP
|
|
||||||
records the feature as deliberately frozen. The comment is wrong and the two
|
|
||||||
`prefs.FloatWithFallback` reads are dead code that make it look implemented.
|
|
||||||
See also 8.2.
|
|
||||||
|
|
||||||
### 4.4 README says "Pause all"; the button says "Disable auto" — low
|
|
||||||
|
|
||||||
[README.md](../README.md) step 6 under *Using The App*. The control is labelled
|
|
||||||
`Disable auto` / `Enable auto` ([jobs_view.go:261](../src/ui/jobs_view.go)).
|
|
||||||
|
|
||||||
### 4.5 A comment cites a function that no longer exists — low
|
|
||||||
|
|
||||||
[jobs_view_helpers.go:11](../src/ui/jobs_view_helpers.go) refers to
|
|
||||||
`app.Service.recordRun`. The function is `executeRun`.
|
|
||||||
|
|
||||||
### 4.6 README narrows when notifications fire — low
|
|
||||||
|
|
||||||
"…whenever a scheduled or manual run exits with a non-zero exit code." The
|
|
||||||
condition is `State == "Failed"`, which also covers timeouts and processes that
|
|
||||||
failed to start.
|
|
||||||
|
|
||||||
### 4.7 The coverage command in TESTS.md does not run on the documented shell — info
|
|
||||||
|
|
||||||
TESTS.md gives the `-coverpkg` invocation in bash form. In the PowerShell
|
|
||||||
environment DEVELOPMENT.md prescribes for Windows, PowerShell splits the
|
|
||||||
comma-separated package list and the command fails with
|
|
||||||
`directory not found`. It needs `--%` (or the whole flag quoted). Worth a note
|
|
||||||
next to the command, since it is the one measurement the doc asks reviewers to
|
|
||||||
reproduce.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Readability and maintainability
|
|
||||||
|
|
||||||
### 5.1 `newJobsView` is one 330-line constructor over shared mutable locals — medium
|
|
||||||
|
|
||||||
[jobs_view.go:30-361](../src/ui/jobs_view.go). Twelve closures share
|
|
||||||
`jobs`, `runtimes`, `selected`, `selectedFolder`, `filteredJobs`, `listView`,
|
|
||||||
and `schedulerPaused`, and several of them patch two or three of those in
|
|
||||||
sequence before calling `refreshView`. Understanding any one handler means
|
|
||||||
reading all of them, because the invariant "`selected` indexes `jobs`, and the
|
|
||||||
list's selection index indexes `filteredJobs`" is maintained by hand in five
|
|
||||||
places.
|
|
||||||
|
|
||||||
ROADMAP already tracks the split. This review adds the reason it matters beyond
|
|
||||||
line count: the state, not the length, is what makes it hard. Extracting a small
|
|
||||||
`jobsViewState` struct with `selectByID`, `applyFilter`, and `snapshot` methods
|
|
||||||
would shrink the file and make 5.2 impossible.
|
|
||||||
|
|
||||||
### 5.2 Selection is tracked by slice index, not by job ID — medium
|
|
||||||
|
|
||||||
`selected` is an index into a snapshot of the jobs slice. Every path that can
|
|
||||||
change the slice — create, delete, filter — patches it explicitly. The path that
|
|
||||||
replaces the whole list does not: adopting a different jobs file emits
|
|
||||||
`JobsLoaded` plus a broad `JobChanged`, the observer calls `refresh()`, and
|
|
||||||
`refreshView` calls `updateDetails(selected)` with an index from the *previous*
|
|
||||||
list. The details pane then describes whichever job now happens to sit at that
|
|
||||||
index, while the list's highlight is untouched.
|
|
||||||
|
|
||||||
Track the selection by `Job.ID` and resolve it to an index at render time.
|
|
||||||
|
|
||||||
### 5.3 `operations.go` mixes three jobs in one file — low
|
|
||||||
|
|
||||||
490 lines: the public mutating operations, the `…Locked` state helpers only they
|
|
||||||
call, and the pure validators/normalizers. ROADMAP already names this as the
|
|
||||||
clearest of the six splits; nothing to add except that it is still the worst
|
|
||||||
overage.
|
|
||||||
|
|
||||||
### 5.4 The nested `fyne.Do` has no explanation — low
|
|
||||||
|
|
||||||
[mainwindow.go:71 and 85](../src/ui/mainwindow.go) — the observer's body already
|
|
||||||
runs inside `fyne.Do`, and the failure-notification block opens a second one.
|
|
||||||
The nesting is deliberate (it defers the toast by one main-thread hop so
|
|
||||||
`UIQueuedAt` can measure that hop for `notify-timing.log`), but nothing says so,
|
|
||||||
and a reader's first instinct is to "simplify" it away and lose the
|
|
||||||
instrumentation. The same block also calls `appendNotificationTimingLog` — a
|
|
||||||
file open, stat, and write — on the UI thread.
|
|
||||||
|
|
||||||
Add the sentence that explains the nesting, and move the log append off the main
|
|
||||||
thread.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Logical errors
|
|
||||||
|
|
||||||
### 6.1 Windows shell quoting picks the wrong program path — medium (reproduced)
|
|
||||||
|
|
||||||
[quoteLeadingWindowsProgramPath](../src/runner/invocation_windows.go) walks the
|
|
||||||
extension list `.exe`, `.cmd`, `.bat`, `.com` **in list order** and takes the
|
|
||||||
first extension that appears anywhere in the string. It should take the
|
|
||||||
extension that appears *earliest*, and only at a token boundary. When the
|
|
||||||
program is a `.bat` or `.cmd` and any argument ends in `.exe`, the `.exe` in the
|
|
||||||
argument is found first and the entire command line is treated as the program
|
|
||||||
path.
|
|
||||||
|
|
||||||
Reproduced by running the function verbatim outside the build:
|
|
||||||
|
|
||||||
| Input (job `Command`) | Produced command line |
|
|
||||||
|---|---|
|
|
||||||
| `C:\My Tools\run.bat D:\in.txt` | `cmd.exe /S /C ""C:\My Tools\run.bat" D:\in.txt"` ✔ |
|
|
||||||
| `C:\My Tools\run.bat C:\Windows\System32\notepad.exe` | `cmd.exe /S /C ""C:\My Tools\run.bat C:\Windows\System32\notepad.exe""` ✘ |
|
|
||||||
| `C:\Program Files\App\deploy.cmd D:\stage\setup.exe` | `cmd.exe /S /C ""C:\Program Files\App\deploy.cmd D:\stage\setup.exe""` ✘ |
|
|
||||||
| `C:\dir.exexample\My Tool\run.bat` | `cmd.exe /S /C "C:\dir.exexample\My Tool\run.bat"` ✘ (never quoted) |
|
|
||||||
|
|
||||||
The two ✘ rows in the middle hand `cmd.exe` a single quoted token that is not a
|
|
||||||
file, so the run fails with a shell-level error the user cannot map back to
|
|
||||||
their job. The last row is the mirror image: a `.exe` substring inside a
|
|
||||||
directory name makes the check conclude the program path has no spaces, so a
|
|
||||||
path that *does* need quoting is left bare.
|
|
||||||
|
|
||||||
Reachable through normal use: it applies whenever the command does not resolve
|
|
||||||
as a direct executable path, which is what happens when the user types a whole
|
|
||||||
command line into the **Command** field — the shape the field's own placeholder
|
|
||||||
and the existing Joplin test fixture both demonstrate.
|
|
||||||
|
|
||||||
Fix: find the earliest extension match across all four extensions, and require
|
|
||||||
the character after it to be a space or end-of-string. Regression test with the
|
|
||||||
four rows above.
|
|
||||||
|
|
||||||
### 6.2 `gosentry.json` and `jobs.json` are written non-atomically — medium
|
|
||||||
|
|
||||||
[storage.writeJSON](../src/storage/store.go) is `os.WriteFile` — truncate, then
|
|
||||||
write. A crash, a power loss, or the process exiting during the write leaves a
|
|
||||||
truncated or empty file, and for `jobs.json` that is every job definition the
|
|
||||||
user has.
|
|
||||||
|
|
||||||
The exposure is larger than it looks because of 2.1: `SaveJobs` runs twice per
|
|
||||||
run, so the window is open constantly on a busy install. And `Service.Stop()` is
|
|
||||||
never called — `ui.Run` has no shutdown path, and the tray's Quit item goes
|
|
||||||
straight to `a.Quit()` ([tray.go:72](../src/ui/tray.go)) — so quitting while a
|
|
||||||
run is completing terminates the process mid-write with nothing to flush.
|
|
||||||
|
|
||||||
Fix: write to `<name>.tmp` in the same directory, `Sync`, then `os.Rename` over
|
|
||||||
the target. Rename is atomic within a volume on both supported platforms. The
|
|
||||||
same treatment is cheap for `runner/logfile.go`, though a torn log file costs
|
|
||||||
much less than a torn jobs file.
|
|
||||||
|
|
||||||
Worth pairing with a `Service.Stop()` call on shutdown, which also makes the
|
|
||||||
`ctx` cancellation the runner already implements actually reachable.
|
|
||||||
|
|
||||||
### 6.3 `PendingRuns` survives a pause and has no ceiling — medium
|
|
||||||
|
|
||||||
[executeRun](../src/app/run.go) drains the queue with
|
|
||||||
`rerun := runtime.PendingRuns > 0 && current.Enabled && !s.paused`. Nothing ever
|
|
||||||
*clears* the counter. Two consequences:
|
|
||||||
|
|
||||||
- **Pause leaks a run.** Pause the scheduler while a `queue`-policy job has a
|
|
||||||
backlog, and the counter stays set. `refreshNextRunLocked` parks the job at
|
|
||||||
"Scheduler paused" and the drain is skipped — correctly, and
|
|
||||||
`TestRunDueQueueDrainSkippedWhenPaused` pins that. But after the user resumes,
|
|
||||||
the stale counter is still there, and the next completed run of that job fires
|
|
||||||
a deferred run that corresponds to an occurrence from before the pause.
|
|
||||||
Disabling a job has the same shape: `SetEnabled(false)` does not clear it.
|
|
||||||
- **No ceiling.** A job whose runs take longer than its interval increments
|
|
||||||
`PendingRuns` on every missed occurrence forever. The job then runs
|
|
||||||
back-to-back indefinitely, and there is no bound, no warning, and nothing in
|
|
||||||
the UI that shows the queue depth.
|
|
||||||
|
|
||||||
Fix: clear `PendingRuns` in `SetGlobalPause(true)` and in `SetEnabled(false)`,
|
|
||||||
and cap it (a small constant, or the number of occurrences in one interval).
|
|
||||||
Document the cap in STANDARDS next to the existing overlap-policy entry, and
|
|
||||||
show the depth in the details pane if it is capped.
|
|
||||||
|
|
||||||
### 6.4 `normalizeJobs` never resolves duplicate IDs — low/medium
|
|
||||||
|
|
||||||
[store.go:180](../src/storage/store.go) assigns an ID only when one is absent
|
|
||||||
(`job.ID <= 0`). A hand-edited `jobs.json` — a workflow the project explicitly
|
|
||||||
supports and README documents — with two entries carrying `"id": 5` produces two
|
|
||||||
jobs that share one `JobRuntime` entry, one schedule-cache entry, and one
|
|
||||||
`SeedStats` bucket. `findByIDLocked` returns the first, so editing or deleting
|
|
||||||
one silently targets the other; both runs write their state onto the same
|
|
||||||
runtime.
|
|
||||||
|
|
||||||
Fix: track seen IDs during normalization and reassign the later duplicate, which
|
|
||||||
is exactly what the existing `next` counter already computes.
|
|
||||||
|
|
||||||
### 6.5 Log file names collide within the same second — low
|
|
||||||
|
|
||||||
[logfile.go:25](../src/runner/logfile.go) builds
|
|
||||||
`20060102-150405_<name>.log`. Two runs of the same job in the same second — a
|
|
||||||
fast job re-run manually, or a queue drain of a sub-second command — write the
|
|
||||||
same path and the second silently overwrites the first. `SeedStats` counts files,
|
|
||||||
so the run history also under-counts. Add a disambiguating suffix when the path
|
|
||||||
already exists.
|
|
||||||
|
|
||||||
### 6.6 Two different averages for the same history — low
|
|
||||||
|
|
||||||
[updateStats](../src/app/run.go) keeps a truncating incremental mean
|
|
||||||
(`(avg*(n-1) + d) / n` in integer arithmetic, so the truncation error
|
|
||||||
compounds), while [aggregateLogStats](../src/runner/seed.go) computes an exact
|
|
||||||
`sum / count`. The same run history therefore reports a different average
|
|
||||||
depending on whether it was seeded from logs at startup or accumulated live —
|
|
||||||
and the two are mixed, because seeds are the starting values that `updateStats`
|
|
||||||
then folds new runs into. Keep a running sum on `JobRuntime` and divide on
|
|
||||||
read.
|
|
||||||
|
|
||||||
### 6.7 Absolute paths are not cleaned; relative ones are — low
|
|
||||||
|
|
||||||
[ResolveConfiguredPath](../src/storage/store.go) returns an absolute path
|
|
||||||
verbatim and only `Clean`s the relative case. `UpdateSettings` decides whether
|
|
||||||
the user is switching jobs files by comparing the resolved path to
|
|
||||||
`Paths.JobsPath` as strings, so `C:/data/jobs.json` and `C:\data\jobs.json` read
|
|
||||||
as two different files and trigger the adoption branch against the file the app
|
|
||||||
is already using. `filepath.Clean` on both sides fixes it.
|
|
||||||
|
|
||||||
### 6.8 Missed occurrences during downtime are dropped — challenge, not a bug
|
|
||||||
|
|
||||||
`adoptJobsLocked` computes each job's first `NextDue` from `time.Now()`, so
|
|
||||||
occurrences that fell while the app was closed never run and never appear in
|
|
||||||
History. This is the right default for a desktop scheduler, but it is not
|
|
||||||
written down anywhere — a user coming from cron with `anacron` habits will
|
|
||||||
assume the opposite. Add it to STANDARDS §Intentional behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Legacy code and migrations
|
|
||||||
|
|
||||||
The file-compatibility discipline STANDARDS describes is genuinely followed:
|
|
||||||
`Config.JobsDir` → `Config.JobsFile` and the retired `"default"` theme value are
|
|
||||||
both converted on load, cleared, and covered by a `storage` test. Nothing found
|
|
||||||
that reads a shape the app cannot write. The findings are smaller.
|
|
||||||
|
|
||||||
### 7.1 `domain.RunRecord` carries dead `yaml:` tags — low
|
|
||||||
|
|
||||||
[record.go](../src/domain/record.go) tags all nine fields `yaml:"…"`. Nothing
|
|
||||||
serialises the type — History is session-only and log files are written as
|
|
||||||
hand-rolled text — and there is no YAML dependency in `go.mod`. Leftover from an
|
|
||||||
earlier format. Delete them, or convert to `json:` if the type is ever meant to
|
|
||||||
be persisted.
|
|
||||||
|
|
||||||
### 7.2 Two compatibility shims with no retirement plan — low
|
|
||||||
|
|
||||||
`Config.JobsDir` (pre-0.15) and `Theme == "default"` (pre-1.0.1) are both
|
|
||||||
read-only shims that rewrite the file into the current shape on the next save,
|
|
||||||
so each one becomes dead the moment a user's config has been saved once by a
|
|
||||||
current build. Neither has a note saying when it can go. Add "remove after
|
|
||||||
<version>" to each, or a single ROADMAP entry that retires both.
|
|
||||||
|
|
||||||
### 7.3 `autostart` exposes two public surfaces for one job — low
|
|
||||||
|
|
||||||
Each of the three implementations exports both the `Manager` methods and the
|
|
||||||
bare `SetAutostart` / `AutostartStatus` functions the methods delegate to. Only
|
|
||||||
the interface is used outside the package (plus the tests). Unexport the
|
|
||||||
functions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Stubs and claimed-but-unimplemented behavior
|
|
||||||
|
|
||||||
### 8.1 "Cleanup disabled" is documented and tested but unreachable — medium
|
|
||||||
|
|
||||||
`CleanupLogs` documents `maxFiles <= 0` and `maxAgeDays <= 0` as "policy
|
|
||||||
disabled", and `TestCleanupLogsZeroLimitsDisableBothPolicies` pins it. The app
|
|
||||||
can never produce that state: `validateConfig` rejects both as
|
|
||||||
"must be a positive number" ([operations.go:469](../src/app/operations.go)), and
|
|
||||||
`loadOrCreateConfig` backfills 0 to 100 / 30 on load
|
|
||||||
([store.go:119](../src/storage/store.go)). So a user cannot turn log cleanup off
|
|
||||||
at all, by GUI or by hand-editing.
|
|
||||||
|
|
||||||
This is also inconsistent with `DefaultTimeoutSeconds`, where the project went
|
|
||||||
to real trouble — a pointer type, a documented three-state table, a dedicated
|
|
||||||
test — precisely so that a meaningful zero would survive.
|
|
||||||
|
|
||||||
Decide one way: either accept 0 as "unlimited" in `validateConfig` and stop
|
|
||||||
backfilling it (documented in STANDARDS alongside the timeout rule), or delete
|
|
||||||
the unreachable branch in `CleanupLogs` and its test. The first is the better
|
|
||||||
outcome — "keep everything" is a real thing to want from a log retention
|
|
||||||
setting.
|
|
||||||
|
|
||||||
### 8.2 Window-size preferences are read but never written — low
|
|
||||||
|
|
||||||
`prefs.FloatWithFallback("window.width", …)` in
|
|
||||||
[run.go:64](../src/ui/run.go) always returns the fallback because no code path
|
|
||||||
writes those keys. Dead reads plus a comment that claims otherwise (4.3).
|
|
||||||
Replace with the constants and leave a one-line pointer to the frozen ROADMAP
|
|
||||||
item.
|
|
||||||
|
|
||||||
### 8.3 `notify-timing.log` shares the retention budget of run logs — low
|
|
||||||
|
|
||||||
[appendNotificationTimingLog](../src/ui/notify_timing.go) writes into
|
|
||||||
`logs_dir` with a `.log` extension, so `CleanupLogs` counts it against
|
|
||||||
`MaxLogFiles` and will delete it once it ages past `MaxLogAgeDays`. It is
|
|
||||||
diagnostic instrumentation shipped in 1.0.2 for the "Faster Windows failure
|
|
||||||
notifications" ROADMAP item, with no note on when it comes out. Give it a
|
|
||||||
different extension (or a `diagnostics/` subdirectory — `CleanupLogs` already
|
|
||||||
skips directories) and add its removal to that ROADMAP entry.
|
|
||||||
|
|
||||||
Items 1.2 (`SchedulerStateChanged`) and 2.3 (`collectActivity`) also belong to
|
|
||||||
this section; they are written up above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. GUI: crutches and layout
|
|
||||||
|
|
||||||
**This section is close to clean, and that is the headline.** The rule in
|
|
||||||
STANDARDS — "a size that must follow the theme is measured at build time, not
|
|
||||||
written as a pixel constant" — is actually observed: `rowOverlap`,
|
|
||||||
`captionColumnWidth`, `textColumnWidth`, `activityRowsHeight`, and
|
|
||||||
`initialSplitOffset` all derive from the theme or from measured text, and the
|
|
||||||
`ui` tests assert the resulting geometry under two themes. The two raw numbers
|
|
||||||
that remain (`commandOutputScroll`'s 460×70 minimum and the `+1` rounding
|
|
||||||
allowance in `activityRowsHeight`) both carry a comment explaining why nothing
|
|
||||||
about them tracks the theme. No layout crutches found.
|
|
||||||
|
|
||||||
The remaining items are small.
|
|
||||||
|
|
||||||
### 9.1 `AutostartStatus` runs PowerShell synchronously on the UI thread — low
|
|
||||||
|
|
||||||
`settingsView` is constructed eagerly during `newMainView`, and its constructor
|
|
||||||
calls `refreshAutostartStatus()` → `svc.AutostartStatus()`. On Windows with
|
|
||||||
autostart enabled that reaches `readShortcut`
|
|
||||||
([autostart_windows.go:124](../src/platform/autostart/autostart_windows.go)),
|
|
||||||
which spawns `powershell.exe` and blocks on `CombinedOutput()` — the same
|
|
||||||
PowerShell cold start ROADMAP measures at 700–900 ms for notifications. It runs
|
|
||||||
before the window is shown, and again on every toggle of either checkbox.
|
|
||||||
|
|
||||||
Given the project already measures and cares about startup time
|
|
||||||
([PERFORMANCE.md](PERFORMANCE.md)), this is worth moving to a goroutine that
|
|
||||||
posts its result back through `fyne.Do`, with the label showing "Checking…"
|
|
||||||
meanwhile.
|
|
||||||
|
|
||||||
### 9.2 Two package-level mutable globals in `tray.go` — low
|
|
||||||
|
|
||||||
`mainWindowHidden` is justified and documented (Fyne exposes no
|
|
||||||
`Window.Visible`). `systemTrayRegistered` is not: it is process-global state
|
|
||||||
that no test can reset, and it exists only because `applyTrayBehavior` is called
|
|
||||||
from two places. Passing it, or hanging both flags off a small struct owned by
|
|
||||||
`Run`, removes the hidden coupling.
|
|
||||||
|
|
||||||
### 9.3 The activity list is refreshed twice per redraw — low
|
|
||||||
|
|
||||||
`refreshView` calls `dp.logs.Refresh()` immediately after `updateDetails`, which
|
|
||||||
already ends in `d.logs.Refresh()` ([jobs_view.go:91](../src/ui/jobs_view.go),
|
|
||||||
[jobs_view_details.go:103](../src/ui/jobs_view_details.go)). Harmless, but it is
|
|
||||||
the shape of duplicate-refresh bug that `TestToolbarButtonRedrawsRowAndDetails`
|
|
||||||
was written to prevent.
|
|
||||||
|
|
||||||
### 9.4 The folder-filter rebuild is repeated three times — low
|
|
||||||
|
|
||||||
`folderSelect.Options = folderOptions(jobs); folderSelect.Refresh()` appears
|
|
||||||
verbatim in the create, edit, and delete handlers. One `rebuildFolders()`
|
|
||||||
closure beside `refreshView`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Under-documented contentious decisions
|
|
||||||
|
|
||||||
REVIEW §8 asks whether a decision a future reader would question has its
|
|
||||||
reasoning recorded. Most do — the platform layer, the timeout pointer, the
|
|
||||||
details-pane width coupling, and the frozen window-size work are all model
|
|
||||||
entries. These four are not.
|
|
||||||
|
|
||||||
- **Single-instance falls back to "start anyway."**
|
|
||||||
[singleinstance.go:34](../src/ui/singleinstance.go) documents *why* it does
|
|
||||||
not abort when port 37653 is held by something that is not GoSentry. It does
|
|
||||||
not document the consequence: two GoSentry processes then run two schedulers
|
|
||||||
against the same `jobs.json` and the same logs directory, each overwriting the
|
|
||||||
other's saves. Combined with 6.2 that is a plausible way to lose the file.
|
|
||||||
- **The instance channel is an unauthenticated localhost TCP port.** Any local
|
|
||||||
process, including one running as another user on a shared machine, can send
|
|
||||||
`show`. Low impact — the command only raises a window — but it is a design
|
|
||||||
choice, not an accident, and it should say so.
|
|
||||||
- **The nested `fyne.Do`** (5.4).
|
|
||||||
- **No catch-up after downtime** (6.8).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Other improvement proposals
|
|
||||||
|
|
||||||
- **Startup parses `gosentry.json` twice**, and `PeekKeepRunningInTray`
|
|
||||||
([store.go:22](../src/storage/store.go)) *creates* the file as a side effect
|
|
||||||
of a function named "Peek", before `OpenStore` runs. Harmless today; a
|
|
||||||
surprising name for a function with a write.
|
|
||||||
- **`-race` wall time is 4 minutes**, 229 s of it `src/ui`. That is the single
|
|
||||||
biggest tax on iteration in this repo and the reason the model
|
|
||||||
recommendations below lean toward first-pass correctness.
|
|
||||||
- **`scripts/test.bat` prints `✓` / `✗`** as UTF-8 in a file `cmd.exe` reads in
|
|
||||||
the OEM code page, so the summary lines render as mojibake on a default
|
|
||||||
Russian or US console. Use ASCII, or `chcp 65001`.
|
|
||||||
- **`dist/` in the working tree holds a 1.0.1 binary and 130 sample run logs.**
|
|
||||||
Correctly gitignored, so this is only a note: the stale binary next to a 1.0.2
|
|
||||||
source tree is an easy thing to hand someone by accident.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested order
|
|
||||||
|
|
||||||
Grouped so that each commit is independently reviewable and each medium finding
|
|
||||||
lands with its regression test.
|
|
||||||
|
|
||||||
1. **4.1 — restore `TestJobListViewIsCompact`, retire the other row.** Smallest,
|
|
||||||
and it restores an enforcement STANDARDS calls mandatory. Do it first so the
|
|
||||||
rest of the work runs against a suite that is honest about itself. TESTS.md
|
|
||||||
changes in the same commit: add the restored test back, drop the
|
|
||||||
`TestDefaultConfigUsesDetailedJobList` row, add the `notify_timing_test.go`
|
|
||||||
table (4.2), and add `PeekKeepRunningInTray` to the deliberate-0% list if
|
|
||||||
that is the intent.
|
|
||||||
2. **6.1 — the Windows quoting bug.** Self-contained, one function, CGO-free
|
|
||||||
package, four-row table test already written out above.
|
|
||||||
3. **6.2 — atomic writes**, plus a `Service.Stop()` on shutdown. Touches one
|
|
||||||
helper and one call site; protects everything else.
|
|
||||||
4. **2.1 — drop the three no-op `SaveJobs` calls**, and retire
|
|
||||||
`TestStartRunLockedRollbackOnSaveFailure` with the rollback it guards. Best
|
|
||||||
done after 6.2, so the durability question is already settled and this is
|
|
||||||
purely a removal.
|
|
||||||
5. **6.3 — `PendingRuns` lifecycle and cap**, with STANDARDS updated alongside.
|
|
||||||
6. **3.1 — cap History and stop rescanning column widths.** The biggest
|
|
||||||
behavioural win; needs a before/after measurement, and it is in `ui`, so it
|
|
||||||
is the item with the slowest feedback loop.
|
|
||||||
7. **3.2, 3.3 — I/O off `mu`, StartOnly context.** Related concurrency
|
|
||||||
cleanups; one commit each.
|
|
||||||
8. **8.1 — decide what a zero retention limit means**, and make the code, the
|
|
||||||
validator, and STANDARDS agree.
|
|
||||||
9. **1.1 — typed Service accessors, retire `Store()`.** Mechanical once decided,
|
|
||||||
but it touches eight UI sites and is best done when nothing else is in
|
|
||||||
flight. Rolls up 1.2, 1.3 and 7.3.
|
|
||||||
10. **5.1, 5.2 — the Jobs view state extraction**, folded into the ROADMAP
|
|
||||||
file-split item rather than done separately. 5.2 is a real defect, so if the
|
|
||||||
split slips, fix the selection-by-ID part on its own.
|
|
||||||
11. **The remaining low items** (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, §10, §11) as a small number of themed cleanup commits.
|
|
||||||
|
|
||||||
CHANGELOG entries are needed for 6.1, 6.2, 6.3, 3.1, 3.3, and 8.1 — those change
|
|
||||||
shipped behavior. The rest is internal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Which model to use
|
|
||||||
|
|
||||||
For running these items in Claude Code. As in the retired test-suite plan, the
|
|
||||||
deciding factor is **not** task size — it is that the feedback loop is slow: the
|
|
||||||
`ui` package needs the MSYS2 UCRT64 toolchain with CGO on, and `src/ui` alone
|
|
||||||
took **229 s** in this review's `go test -race ./...` run — every other package
|
|
||||||
in the tree finished in under 8 s. A model that gets an edit right on the first
|
|
||||||
pass is worth more than a faster one that needs a second four-minute build to
|
|
||||||
discover it was wrong.
|
|
||||||
|
|
||||||
| Item | Model | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 — restore one test, sync TESTS.md | **Haiku 4.5** (`claude-haiku-4-5`) | The test is recoverable verbatim from `git show 5b0e6fe^:src/domain/config_test.go`, and both judgment calls — that the removal was accidental, and that only one of the two is worth restoring — are already settled in §4.1. What is left is a paste plus four doc-table edits, in `domain`, which runs in ~2 s. Nothing to weigh. |
|
|
||||||
| 2 — Windows quoting | **Sonnet 5** (`claude-sonnet-5`) | The defect and the four expected outputs are already pinned in this document, so the judgment is made; writing the earliest-match-at-a-boundary scan and its table test is careful execution work. `runner` needs no CGO and its Windows-gated test file runs in seconds. |
|
|
||||||
| 3 — atomic writes + `Service.Stop()` | **Sonnet 5** | Temp-file-then-rename is a known pattern; the only real decisions (same directory, `Sync` before rename, what to do with a leftover `.tmp`) are stated. The `Stop()` wiring in `run.go` is two lines. |
|
|
||||||
| 4 — remove the no-op saves | **Opus 5** (`claude-opus-5`) | This one is a judgment call disguised as a deletion. It requires re-deriving, against the current `domain.Job`, that no durable field changes on those paths — and being willing to say "actually one does" instead of deleting the safety net. It also retires an existing regression test, which is the sort of change that should not be made by a model optimising for completing the task. |
|
|
||||||
| 5 — `PendingRuns` lifecycle and cap | **Opus 5** | Interacting state across pause, disable, drain, and the tick loop, with three existing queue tests that must keep passing and a cap whose value is a design decision, not a lookup. `app` is CGO-free, but the reasoning is the cost here, not the build. |
|
|
||||||
| 6 — History cap + incremental column widths | **Opus 5** | The item with the worst feedback loop (in `ui`, 229 s per attempt) and the one where a plausible-looking fix can be wrong: widths must never shrink below what is on screen, and the cap interacts with the sort toggle and the cached `rows` snapshot that `TestHistorySortToggleKeepsRowsInSync` exists to protect. Fast mode (`/fast`) is worth enabling here specifically, since the wait is real. |
|
|
||||||
| 7 — I/O off `mu`, StartOnly context | **Opus 5** | Lock-scope changes are exactly where a confident-but-wrong edit is expensive: moving `CleanupLogs` out from under `mu` must not move the snapshot reads with it. The StartOnly half requires knowing why `exec.CommandContext` keeps a goroutine alive without `Wait` — reasoning about the standard library's internals, not about this repo. |
|
|
||||||
| 8 — zero retention limits | **Sonnet 5** | Once the direction is chosen (accept 0 as unlimited, per §8.1), the change is a validator branch, a load branch, a STANDARDS entry, and two tests, all in CGO-free packages. If the decision goes the other way — deleting the branch and its test — it is smaller still. |
|
|
||||||
| 9 — typed Service accessors | **Sonnet 5** | Eight mechanical call-site conversions plus two new accessors. The design is settled in §1.1; the work is breadth, not depth. Half the sites are in `ui`, so budget one slow verification run rather than several. |
|
|
||||||
| 10 — Jobs view state extraction | **Opus 5** | The ROADMAP already says why: a split reads as pure movement while quietly dropping a function, and this one has to break up a constructor rather than move whole functions. The selection-by-ID defect has to survive the move as a fix, not be re-introduced by it. |
|
|
||||||
| 11 — the low-severity cleanups | **Sonnet 5**, or **Haiku 4.5** for the doc-only ones | Each is small and independently verifiable. Group the CGO-free ones (`domain`, `storage`, `runner`, `app`) into one pass and the `ui` ones into another, so the 229 s build is paid once rather than per item. |
|
|
||||||
|
|
||||||
Two notes on this table:
|
|
||||||
|
|
||||||
- **Sonnet 5 is the reasonable single choice** if you would rather not switch
|
|
||||||
models per item: items 4–7 and 10 are the only ones that really reward the
|
|
||||||
step up, and of those only 6 and 7 are likely to go wrong quietly. Sonnet 5's
|
|
||||||
introductory pricing runs through **2026-08-31** ($2/$10 per MTok vs $3/$15
|
|
||||||
after), against Opus 5's $5/$25.
|
|
||||||
- **Fast mode is available on Opus 5** (toggle with `/fast`). It is the same
|
|
||||||
model with higher output throughput, not a downgrade, but it bills at $10/$50,
|
|
||||||
so it only pays for itself when you are actually waiting on output. On this
|
|
||||||
plan that is item 6 — and, if you batch them, the `ui` half of item 11.
|
|
||||||
+22
-2
@@ -18,15 +18,35 @@ machine):** average **773 ms** per toast (695–874 ms), dominated by PowerShell
|
|||||||
cold start. Re-run the script when comparing after a native toast implementation.
|
cold start. Re-run the script when comparing after a native toast implementation.
|
||||||
|
|
||||||
**App-side timing:** each failure notification appends one line to
|
**App-side timing:** each failure notification appends one line to
|
||||||
`logs/notify-timing.log` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
||||||
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
||||||
toast latency is not included.
|
toast latency is not included. The `.tsv` extension keeps it out of
|
||||||
|
`runner.CleanupLogs`, which only manages `.log` files — this file is
|
||||||
|
diagnostic instrumentation for this item, not job output, and should be
|
||||||
|
removed (or unified with the run-log retention policy under its own knob) once
|
||||||
|
the native-toast direction below lands and the timing data is no longer
|
||||||
|
needed.
|
||||||
|
|
||||||
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
||||||
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
||||||
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
||||||
needs the same treatment.
|
needs the same treatment.
|
||||||
|
|
||||||
|
### Retire the config compatibility shims
|
||||||
|
|
||||||
|
Two read-only shims in `storage.loadOrCreateConfig` rewrite an old file into
|
||||||
|
the current shape on the next save, so each becomes dead the moment a user's
|
||||||
|
config has been saved once by a build that has it:
|
||||||
|
|
||||||
|
- `Config.JobsDir` (pre-0.15, superseded by `Config.JobsFile`).
|
||||||
|
- `Theme == "default"` (pre-1.0.1, superseded by `ThemeSystem`).
|
||||||
|
|
||||||
|
Neither has an expiry. Remove both — the field, the migration branch, and
|
||||||
|
`TestLoadOrCreateConfigMigratesJobsDir` /
|
||||||
|
`TestLoadOrCreateConfigMigratesLegacyThemeDefault` — once a release has shipped
|
||||||
|
long enough that a config file still carrying either old shape is not a
|
||||||
|
realistic upgrade path GoSentry needs to support.
|
||||||
|
|
||||||
### Dynamic tray icon toggle
|
### Dynamic tray icon toggle
|
||||||
|
|
||||||
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
||||||
|
|||||||
@@ -121,6 +121,22 @@ change to their shape has to stay compatible on its own.
|
|||||||
an occurrence that fired before the pause/disable. The details pane appends
|
an occurrence that fired before the pause/disable. The details pane appends
|
||||||
", N queued" to the statistics line via `DisplayStats` whenever the count is
|
", N queued" to the statistics line via `DisplayStats` whenever the count is
|
||||||
non-zero.
|
non-zero.
|
||||||
|
- **Single-instance arbitration falls back to "start anyway" when the port is
|
||||||
|
held by something else.** `acquireSingleInstance` (`singleinstance.go`)
|
||||||
|
binds `127.0.0.1:37653`; if that fails and a dial to the same address does
|
||||||
|
not answer as GoSentry either, startup continues rather than refusing to
|
||||||
|
open because of an unrelated local listener. The consequence is deliberate
|
||||||
|
but worth spelling out: two GoSentry processes can then run two schedulers
|
||||||
|
against the same `jobs.json` and the same logs directory, each overwriting
|
||||||
|
the other's saves. Atomic writes (`writeFileAtomic`) prevent a *torn* file
|
||||||
|
from a concurrent write, but not one process's save clobbering the other's.
|
||||||
|
- **The single-instance channel is an unauthenticated localhost TCP port.**
|
||||||
|
Port 37653 accepts one command, `"show"`, from any local process — including
|
||||||
|
one running as a different user on a shared machine. This is a deliberate
|
||||||
|
scope choice, not an oversight: the command only raises the existing window,
|
||||||
|
so the impact of an unwelcome sender is a window popping up, not data
|
||||||
|
exposure or control. Anything with a larger blast radius on that channel
|
||||||
|
would need real authentication.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
+31
-3
@@ -26,6 +26,12 @@ The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
|
|||||||
that means the MSYS2 UCRT64 toolchain described in
|
that means the MSYS2 UCRT64 toolchain described in
|
||||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||||
|
|
||||||
|
`src/ui` dominates `go test -race ./...`'s wall time — around 229s in the
|
||||||
|
2026-08-05 whole-project review, against under 8s for every other package
|
||||||
|
combined. Budget iteration accordingly: a change confined to `domain`,
|
||||||
|
`storage`, `runner`, `scheduler`, or `app` gets a fast feedback loop; a `ui`
|
||||||
|
change does not.
|
||||||
|
|
||||||
### Manual test commands
|
### Manual test commands
|
||||||
|
|
||||||
Run all tests:
|
Run all tests:
|
||||||
@@ -63,6 +69,15 @@ covered by the `app` tests. Measure the engine packages together instead:
|
|||||||
go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
||||||
|
splits the comma-separated `-coverpkg` list on its own and the command fails
|
||||||
|
with `directory not found`. Use the stop-parsing token, or quote the whole
|
||||||
|
flag:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test --% -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
|
```
|
||||||
|
|
||||||
That figure was 84.4% at the 2026-08-04 review, which is the number to compare
|
That figure was 84.4% at the 2026-08-04 review, which is the number to compare
|
||||||
against before concluding that coverage has slipped.
|
against before concluding that coverage has slipped.
|
||||||
|
|
||||||
@@ -245,6 +260,8 @@ Tests JSON round-tripping, default generation, and backward compatibility.
|
|||||||
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
||||||
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
||||||
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
||||||
|
| `TestNormalizeJobsReassignsDuplicateIDs` | Verifies that a hand-edited `jobs.json` with two entries sharing one ID gets the later duplicates reassigned instead of colliding on one runtime. |
|
||||||
|
| `TestResolveConfiguredPathCleansAbsolutePaths` | Verifies (Windows only) that forward-slash and backslash spellings of the same absolute path resolve to the same string. |
|
||||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
|
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
|
||||||
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
|
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
|
||||||
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
||||||
@@ -376,6 +393,19 @@ Tests log-file cleanup by age and by count.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### src/runner/logfile_test.go
|
||||||
|
|
||||||
|
**Package:** `runner`
|
||||||
|
|
||||||
|
Tests the disambiguating suffix `writeRunLog` applies when two runs land on
|
||||||
|
the same second.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestUniqueLogPathAvoidsCollision` | Verifies repeated calls for the same file name return distinct paths instead of silently overwriting an existing log. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### src/platform/autostart/autostart_windows_test.go
|
### src/platform/autostart/autostart_windows_test.go
|
||||||
|
|
||||||
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
||||||
@@ -508,8 +538,6 @@ column-width behaviour of the assembled table.
|
|||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestCollectActivityMergesAndSorts` | Verifies per-job logs are merged and sorted by time. |
|
|
||||||
| `TestCollectActivitySkipsMissingRuntimes` | Verifies missing runtime entries are skipped safely. |
|
|
||||||
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
||||||
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
||||||
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
||||||
@@ -593,7 +621,7 @@ Tests the failure-notification timing diagnostics added in 1.0.2.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). |
|
| `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). |
|
||||||
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.log` with its header on first write and appends a row containing the job name. |
|
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.tsv` with its header on first write and appends a row containing the job name. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ REM Runs go vet and go test with race detection
|
|||||||
REM Move to repository root
|
REM Move to repository root
|
||||||
cd /d "%~dp0\.."
|
cd /d "%~dp0\.."
|
||||||
|
|
||||||
|
REM This file is UTF-8 (the ✓/✗ below). cmd.exe reads batch files in the
|
||||||
|
REM console's active code page, which defaults to the system locale (e.g.
|
||||||
|
REM CP866 on Russian Windows) rather than UTF-8, so without this the two
|
||||||
|
REM symbols render as mojibake. Switching the console to UTF-8 first fixes
|
||||||
|
REM that; >nul silences chcp's own "Active code page" confirmation line.
|
||||||
|
chcp 65001 >nul
|
||||||
|
|
||||||
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
|
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
|
||||||
REM expected by the Windows build; prepending it keeps the script self-contained
|
REM expected by the Windows build; prepending it keeps the script self-contained
|
||||||
REM without permanently changing the user's system PATH.
|
REM without permanently changing the user's system PATH.
|
||||||
|
|||||||
+12
-5
@@ -1,18 +1,25 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstallDesktopIcon installs the application's .desktop file and icon on
|
// InstallDesktopIcon installs the application's .desktop file and icon on
|
||||||
// Linux (no-op on other platforms). The resulting icon path is stored in
|
// Linux (no-op on other platforms). The resulting icon path is stored in
|
||||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
// store.Paths.DesktopIcon so ApplyAutostart can reference it. A failure is
|
||||||
|
// reported through ErrorOccurred rather than discarded, so the visible symptom
|
||||||
|
// (a generic dock icon) has an explanation in History instead of none.
|
||||||
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes)
|
||||||
s.mu.Lock()
|
if err != nil {
|
||||||
s.store.Paths.DesktopIcon = iconPath
|
s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
|
||||||
s.mu.Unlock()
|
return
|
||||||
}
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.store.Paths.DesktopIcon = iconPath
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutostartStatus reports whether the platform autostart entry matches the
|
// AutostartStatus reports whether the platform autostart entry matches the
|
||||||
|
|||||||
+2
-1
@@ -253,7 +253,8 @@ func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
|
|||||||
rt.MaxDurationMS = r.DurationMS
|
rt.MaxDurationMS = r.DurationMS
|
||||||
}
|
}
|
||||||
rt.TimedRunCount++
|
rt.TimedRunCount++
|
||||||
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.TimedRunCount-1) + r.DurationMS) / int64(rt.TimedRunCount)
|
rt.DurationSumMS += r.DurationMS
|
||||||
|
rt.AvgDurationMS = rt.DurationSumMS / int64(rt.TimedRunCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runningOutput is the placeholder output shown while a job is running, before
|
// runningOutput is the placeholder output shown while a job is running, before
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ func TestUpdateStats(t *testing.T) {
|
|||||||
if rt.AvgDurationMS != 233 {
|
if rt.AvgDurationMS != 233 {
|
||||||
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
|
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
|
||||||
}
|
}
|
||||||
|
// AvgDurationMS must always be exactly DurationSumMS/TimedRunCount — a stored
|
||||||
|
// sum divided once, not an incremental mean that truncates on every step and
|
||||||
|
// compounds error over a long-running job.
|
||||||
|
if rt.DurationSumMS != 700 {
|
||||||
|
t.Errorf("DurationSumMS = %d, want 700", rt.DurationSumMS)
|
||||||
|
}
|
||||||
|
if want := rt.DurationSumMS / int64(rt.TimedRunCount); rt.AvgDurationMS != want {
|
||||||
|
t.Errorf("AvgDurationMS = %d, want DurationSumMS/TimedRunCount = %d", rt.AvgDurationMS, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
|
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
|
|||||||
runtime.AvgDurationMS = seed.AvgDurationMS
|
runtime.AvgDurationMS = seed.AvgDurationMS
|
||||||
runtime.MaxDurationMS = seed.MaxDurationMS
|
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||||
runtime.TimedRunCount = seed.TimedRunCount
|
runtime.TimedRunCount = seed.TimedRunCount
|
||||||
|
runtime.DurationSumMS = seed.DurationSumMS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ package domain
|
|||||||
// output is also written to a log file; the in-memory Output copy exists so the
|
// output is also written to a log file; the in-memory Output copy exists so the
|
||||||
// latest run can be displayed without reopening the log on every repaint.
|
// latest run can be displayed without reopening the log on every repaint.
|
||||||
type RunRecord struct {
|
type RunRecord struct {
|
||||||
Time string `yaml:"time"`
|
Time string
|
||||||
JobID int `yaml:"job_id"`
|
JobID int
|
||||||
JobName string `yaml:"job_name"`
|
JobName string
|
||||||
Trigger string `yaml:"trigger,omitempty"`
|
Trigger string
|
||||||
State string `yaml:"state"`
|
State string
|
||||||
Detail string `yaml:"detail"`
|
Detail string
|
||||||
LogFile string `yaml:"log_file,omitempty"`
|
LogFile string
|
||||||
Output string `yaml:"output,omitempty"`
|
Output string
|
||||||
DurationMS int64 `yaml:"duration_ms,omitempty"`
|
DurationMS int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ type JobRuntime struct {
|
|||||||
// launches that round to 0) increment RunCount but not this. StartOnly runs
|
// launches that round to 0) increment RunCount but not this. StartOnly runs
|
||||||
// otherwise contribute their launch latency.
|
// otherwise contribute their launch latency.
|
||||||
TimedRunCount int
|
TimedRunCount int
|
||||||
|
// DurationSumMS is the running total of every timed run's duration.
|
||||||
|
// AvgDurationMS is always DurationSumMS/TimedRunCount, computed fresh on each
|
||||||
|
// update rather than folded incrementally — an incremental integer mean
|
||||||
|
// truncates on every step, and the error compounds over the life of a job
|
||||||
|
// that keeps running. A stored sum divided once per update matches the exact
|
||||||
|
// sum/count average runner.aggregateLogStats computes when seeding from logs,
|
||||||
|
// so the two no longer disagree about the same run history.
|
||||||
|
DurationSumMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRuntime builds the initial runtime state for a freshly loaded or created
|
// NewRuntime builds the initial runtime state for a freshly loaded or created
|
||||||
|
|||||||
@@ -64,5 +64,3 @@ func LogArguments(arguments string) string {
|
|||||||
}
|
}
|
||||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func logArguments(arguments string) string { return LogArguments(arguments) }
|
|
||||||
|
|||||||
+22
-2
@@ -23,9 +23,9 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
|
|||||||
// by run time. The job name is included for human scanning, but sanitized to
|
// by run time. The job name is included for human scanning, but sanitized to
|
||||||
// avoid characters that are invalid on Windows or awkward on shells.
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||||
path := filepath.Join(logsDir, fileName)
|
path := uniqueLogPath(logsDir, fileName)
|
||||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
||||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, LogArguments(job.Arguments), job.StartOnly, output)
|
||||||
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
|
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write log file: %w", err)
|
return "", fmt.Errorf("write log file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,26 @@ func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uniqueLogPath returns a path for fileName in dir, appending a disambiguating
|
||||||
|
// "-2", "-3", … suffix before the extension if the plain name is already
|
||||||
|
// taken. Two runs of the same job in the same second — a fast manual re-run,
|
||||||
|
// or a sub-second queue drain — would otherwise share one timestamp and the
|
||||||
|
// second write would silently overwrite the first.
|
||||||
|
func uniqueLogPath(dir, fileName string) string {
|
||||||
|
path := filepath.Join(dir, fileName)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(fileName)
|
||||||
|
base := strings.TrimSuffix(fileName, ext)
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, n, ext))
|
||||||
|
if _, err := os.Stat(candidate); err != nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeFileName(name string) string {
|
func sanitizeFileName(name string) string {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ func startOnlyOutput(job domain.Job, pid int) string {
|
|||||||
builder.WriteString("command:\n")
|
builder.WriteString("command:\n")
|
||||||
builder.WriteString(job.Command + "\n\n")
|
builder.WriteString(job.Command + "\n\n")
|
||||||
builder.WriteString("arguments:\n")
|
builder.WriteString("arguments:\n")
|
||||||
builder.WriteString(logArguments(job.Arguments))
|
builder.WriteString(LogArguments(job.Arguments))
|
||||||
builder.WriteString("\n\nstart_only:\ntrue")
|
builder.WriteString("\n\nstart_only:\ntrue")
|
||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ func TestLogArguments(t *testing.T) {
|
|||||||
{"--flag\n--value", "--flag\n--value"},
|
{"--flag\n--value", "--flag\n--value"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := logArguments(tc.input); got != tc.want {
|
if got := LogArguments(tc.input); got != tc.want {
|
||||||
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
t.Errorf("LogArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type SeededStats struct {
|
|||||||
AvgDurationMS int64
|
AvgDurationMS int64
|
||||||
MaxDurationMS int64
|
MaxDurationMS int64
|
||||||
TimedRunCount int
|
TimedRunCount int
|
||||||
|
// DurationSumMS is the running total AvgDurationMS was computed from, folded
|
||||||
|
// into JobRuntime.DurationSumMS so app.updateStats continues the same exact
|
||||||
|
// sum instead of restarting from a value it would have to reverse-multiply.
|
||||||
|
DurationSumMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedStats scans logsDir once and reconstructs per-job execution-time
|
// SeedStats scans logsDir once and reconstructs per-job execution-time
|
||||||
@@ -113,6 +117,7 @@ func aggregateLogStats(files []logSummary) SeededStats {
|
|||||||
}
|
}
|
||||||
if durationCount > 0 {
|
if durationCount > 0 {
|
||||||
stats.TimedRunCount = durationCount
|
stats.TimedRunCount = durationCount
|
||||||
|
stats.DurationSumMS = durationSum
|
||||||
stats.AvgDurationMS = durationSum / int64(durationCount)
|
stats.AvgDurationMS = durationSum / int64(durationCount)
|
||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func TestSeedStatsBasic(t *testing.T) {
|
|||||||
if s.AvgDurationMS != 400 {
|
if s.AvgDurationMS != 400 {
|
||||||
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
|
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
|
||||||
}
|
}
|
||||||
|
if s.DurationSumMS != 1200 {
|
||||||
|
t.Errorf("DurationSumMS = %d, want 1200", s.DurationSumMS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
|
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
|
||||||
|
|||||||
+21
-4
@@ -19,6 +19,13 @@ type Store struct {
|
|||||||
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
|
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
|
||||||
// decisions that must run before app.Open(). On error it returns the built-in
|
// decisions that must run before app.Open(). On error it returns the built-in
|
||||||
// default.
|
// default.
|
||||||
|
//
|
||||||
|
// Despite the name, this can write: loadOrCreateConfig creates gosentry.json
|
||||||
|
// with defaults on first run, the same as OpenStore does moments later when
|
||||||
|
// app.Open() parses the now-existing file again. The double parse and the
|
||||||
|
// write-on-read are both harmless — the second read just sees the file the
|
||||||
|
// first one created — but worth knowing before adding a third startup path
|
||||||
|
// that also wants an early look at the config.
|
||||||
func PeekKeepRunningInTray() bool {
|
func PeekKeepRunningInTray() bool {
|
||||||
paths, err := ResolvePaths()
|
paths, err := ResolvePaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|||||||
|
|
||||||
func normalizeJobs(jobs []domain.Job) {
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
next := 1
|
next := 1
|
||||||
|
seen := make(map[int]bool, len(jobs))
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
job := &jobs[index]
|
job := &jobs[index]
|
||||||
if job.ID <= 0 {
|
if job.ID <= 0 || seen[job.ID] {
|
||||||
// IDs are assigned only when absent. Existing IDs stay stable because
|
// IDs are assigned only when absent or already claimed by an earlier job
|
||||||
// History and future log associations use them to identify jobs.
|
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||||
|
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||||
|
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||||
|
// because History and future log associations use them to identify jobs.
|
||||||
job.ID = next
|
job.ID = next
|
||||||
}
|
}
|
||||||
|
seen[job.ID] = true
|
||||||
if job.ID >= next {
|
if job.ID >= next {
|
||||||
next = job.ID + 1
|
next = job.ID + 1
|
||||||
}
|
}
|
||||||
@@ -241,7 +253,12 @@ func normalizeJobs(jobs []domain.Job) {
|
|||||||
// apply the same rule to a path the user has typed but not yet saved.
|
// apply the same rule to a path the user has typed but not yet saved.
|
||||||
func ResolveConfiguredPath(appDir string, path string) string {
|
func ResolveConfiguredPath(appDir string, path string) string {
|
||||||
if filepath.IsAbs(path) {
|
if filepath.IsAbs(path) {
|
||||||
return path
|
// Cleaned so two spellings of the same file (forward vs. backslashes, a
|
||||||
|
// trailing separator) resolve to the same string. UpdateSettings compares
|
||||||
|
// this against Paths.JobsPath to decide whether the jobs file is changing,
|
||||||
|
// so an uncleaned path here could trigger a spurious adoption against the
|
||||||
|
// file the app is already using.
|
||||||
|
return filepath.Clean(path)
|
||||||
}
|
}
|
||||||
// Relative paths are resolved against the executable directory, not the
|
// Relative paths are resolved against the executable directory, not the
|
||||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -145,6 +146,50 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNormalizeJobsReassignsDuplicateIDs pins the fix for a hand-edited
|
||||||
|
// jobs.json carrying two entries with the same ID: without reassignment both
|
||||||
|
// would share one JobRuntime, one schedule-cache entry, and one SeedStats
|
||||||
|
// bucket, so editing or deleting either would silently affect both.
|
||||||
|
func TestNormalizeJobsReassignsDuplicateIDs(t *testing.T) {
|
||||||
|
jobs := []domain.Job{
|
||||||
|
{ID: 5, Name: "First"},
|
||||||
|
{ID: 5, Name: "Second"},
|
||||||
|
{ID: 5, Name: "Third"},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
|
||||||
|
seen := make(map[int]bool, len(jobs))
|
||||||
|
for _, job := range jobs {
|
||||||
|
if seen[job.ID] {
|
||||||
|
t.Fatalf("ID %d assigned to more than one job after normalization: %+v", job.ID, jobs)
|
||||||
|
}
|
||||||
|
seen[job.ID] = true
|
||||||
|
}
|
||||||
|
if jobs[0].ID != 5 {
|
||||||
|
t.Errorf("first occurrence should keep its ID: got %d, want 5", jobs[0].ID)
|
||||||
|
}
|
||||||
|
if jobs[1].ID == 5 || jobs[2].ID == 5 {
|
||||||
|
t.Errorf("later duplicates should be reassigned away from 5: got %d, %d", jobs[1].ID, jobs[2].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveConfiguredPathCleansAbsolutePaths pins the fix for two spellings
|
||||||
|
// of the same absolute path (forward vs. backslashes) resolving to different
|
||||||
|
// strings: UpdateSettings compares this against Paths.JobsPath as strings to
|
||||||
|
// decide whether the jobs file is changing, so an uncleaned path here could
|
||||||
|
// trigger a spurious adoption against the file already in use.
|
||||||
|
func TestResolveConfiguredPathCleansAbsolutePaths(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("backslash vs. forward-slash spellings of the same path are a Windows-only ambiguity")
|
||||||
|
}
|
||||||
|
got := ResolveConfiguredPath(`C:\app`, "C:/data/jobs.json")
|
||||||
|
want := ResolveConfiguredPath(`C:\app`, `C:\data\jobs.json`)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("forward-slash and backslash spellings resolved differently: %q vs %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
paths := Paths{
|
paths := Paths{
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
@@ -26,22 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
|
||||||
var events []event
|
|
||||||
for _, current := range jobs {
|
|
||||||
// At startup this is usually empty because jobs.json does not persist
|
|
||||||
// runtime logs. The function still centralizes the merge for future
|
|
||||||
// history loading from log metadata.
|
|
||||||
if rt := runtimes[current.ID]; rt != nil {
|
|
||||||
events = append(events, rt.Logs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(events, func(left int, right int) bool {
|
|
||||||
return events[left].Time < events[right].Time
|
|
||||||
})
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// textWidth measures how wide s renders at the theme's current body text size.
|
// textWidth measures how wide s renders at the theme's current body text size.
|
||||||
func textWidth(s string) float32 {
|
func textWidth(s string) float32 {
|
||||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
@@ -57,31 +55,6 @@ func TestIndexOfID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectActivityMergesAndSorts(t *testing.T) {
|
|
||||||
jobs := []job{
|
|
||||||
{ID: 1, Name: "A"},
|
|
||||||
{ID: 2, Name: "B"},
|
|
||||||
}
|
|
||||||
runtimes := map[int]*domain.JobRuntime{
|
|
||||||
1: {Logs: []domain.RunRecord{{Time: "2026-01-02 10:00:00", JobID: 1}}},
|
|
||||||
2: {Logs: []domain.RunRecord{{Time: "2026-01-01 09:00:00", JobID: 2}}},
|
|
||||||
}
|
|
||||||
got := collectActivity(jobs, runtimes)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("len = %d, want 2", len(got))
|
|
||||||
}
|
|
||||||
if got[0].Time != "2026-01-01 09:00:00" || got[1].Time != "2026-01-02 10:00:00" {
|
|
||||||
t.Errorf("sort order = %v, want ascending by Time", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectActivitySkipsMissingRuntimes(t *testing.T) {
|
|
||||||
jobs := []job{{ID: 1, Name: "A"}}
|
|
||||||
if got := collectActivity(jobs, nil); len(got) != 0 {
|
|
||||||
t.Errorf("nil runtimes: got %v, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHistoryCellText(t *testing.T) {
|
func TestHistoryCellText(t *testing.T) {
|
||||||
events := []event{{
|
events := []event{{
|
||||||
Time: "2026-06-01 12:00:00",
|
Time: "2026-06-01 12:00:00",
|
||||||
|
|||||||
+3
-1
@@ -85,8 +85,10 @@ func (v *jobsView) refresh() {
|
|||||||
// it is re-read here rather than mirrored from the tap handler alone — that is
|
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||||
// what makes this view a consumer of SchedulerStateChanged.
|
// what makes this view a consumer of SchedulerStateChanged.
|
||||||
v.applySchedulerState(v.svc.Config().Paused)
|
v.applySchedulerState(v.svc.Config().Paused)
|
||||||
|
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
||||||
|
// paths do), so refreshing the activity list again here would redraw it twice
|
||||||
|
// per call.
|
||||||
v.updateDetails()
|
v.updateDetails()
|
||||||
v.dp.logs.Refresh()
|
|
||||||
v.list.Refresh()
|
v.list.Refresh()
|
||||||
v.syncListSelection()
|
v.syncListSelection()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
||||||
// "Selected job activity" panel. Logs are stored newest-first (see
|
// "Selected job activity" panel. Logs are stored newest-first (see
|
||||||
// app.Service.recordRun), so the leading entries are the latest; the result is
|
// app.prependLog), so the leading entries are the latest; the result is
|
||||||
// capped at maxJobActivityRows.
|
// capped at maxJobActivityRows.
|
||||||
func lastJobLogs(logs []event) []event {
|
func lastJobLogs(logs []event) []event {
|
||||||
n := len(logs)
|
n := len(logs)
|
||||||
|
|||||||
+9
-15
@@ -21,20 +21,11 @@ const runRecordTimeLayout = "2006-01-02 15:04:05"
|
|||||||
type job = domain.Job
|
type job = domain.Job
|
||||||
type event = domain.RunRecord
|
type event = domain.RunRecord
|
||||||
|
|
||||||
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
|
func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
// History is session-only: jobs.json never persists JobRuntime.Logs (see
|
||||||
|
// domain.JobRuntime), so there is nothing to seed the History tab with at
|
||||||
// Build the initial event history from the current runtime state. Jobs and
|
// startup. It starts empty and fills as events arrive.
|
||||||
// runtimes are read here only for this one-time initialization; the jobs view
|
events := newHistoryLog(nil)
|
||||||
// owns all subsequent state via its own syncFromService closure.
|
|
||||||
initialJobs := svc.Jobs()
|
|
||||||
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
|
|
||||||
for _, j := range initialJobs {
|
|
||||||
if rt := svc.Runtime(j.ID); rt != nil {
|
|
||||||
initialRuntimes[j.ID] = rt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events := newHistoryLog(collectActivity(initialJobs, initialRuntimes))
|
|
||||||
|
|
||||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||||
|
|
||||||
@@ -106,12 +97,15 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
|||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
// Installed after Subscribe so a failure reaches History through
|
||||||
|
// ErrorOccurred instead of being emitted to no listener.
|
||||||
|
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||||
svc.Start()
|
svc.Start()
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
||||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
|
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc, tray)),
|
||||||
)
|
)
|
||||||
tabs.SetTabLocation(container.TabLocationTop)
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
|||||||
svc := app.NewService(store, nil)
|
svc := app.NewService(store, nil)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
content, _ := newMainView(w, svc)
|
content, _ := newMainView(w, svc, &trayState{})
|
||||||
min := content.MinSize()
|
min := content.MinSize()
|
||||||
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
||||||
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
|
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
|
||||||
@@ -111,7 +111,7 @@ func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
|
|||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc, &trayState{})
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
|
|
||||||
table := historyTable(t, content)
|
table := historyTable(t, content)
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const notificationTimingLogName = "notify-timing.log"
|
// notificationTimingLogName deliberately does not end in .log: runner.CleanupLogs
|
||||||
|
// only manages .log files in the logs directory, and this diagnostic file
|
||||||
|
// should not be subject to (or counted against) that retention policy.
|
||||||
|
const notificationTimingLogName = "notify-timing.tsv"
|
||||||
|
|
||||||
// notificationTiming captures wall-clock points from a failed run through
|
// notificationTiming captures wall-clock points from a failed run through
|
||||||
// SendNotification. It does not include OS toast display latency — Fyne on
|
// SendNotification. It does not include OS toast display latency — Fyne on
|
||||||
|
|||||||
+9
-10
@@ -17,9 +17,10 @@ import (
|
|||||||
const appID = "ru.mixeme.gosentry.desktop"
|
const appID = "ru.mixeme.gosentry.desktop"
|
||||||
|
|
||||||
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
||||||
// on first launch (later launches restore the last size from preferences).
|
// on every launch. Window size persistence is frozen (see ROADMAP.md), so
|
||||||
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
|
// there is no saved size to restore. Fyne enforces the assembled content's
|
||||||
// they only take effect if the content actually fits within them.
|
// MinSize as a hard floor over these, so they only take effect if the content
|
||||||
|
// actually fits within them.
|
||||||
const defaultWindowWidth = 1024
|
const defaultWindowWidth = 1024
|
||||||
const defaultWindowHeight = 660
|
const defaultWindowHeight = 660
|
||||||
|
|
||||||
@@ -60,10 +61,7 @@ func Run(startInTray bool) {
|
|||||||
|
|
||||||
w := a.NewWindow("GoSentry " + app.Version)
|
w := a.NewWindow("GoSentry " + app.Version)
|
||||||
setWindowsNotificationIcon()
|
setWindowsNotificationIcon()
|
||||||
prefs := a.Preferences()
|
w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
|
||||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
|
||||||
w.Resize(fyne.NewSize(winW, winH))
|
|
||||||
svc, err := app.Open()
|
svc, err := app.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
||||||
@@ -73,13 +71,14 @@ func Run(startInTray bool) {
|
|||||||
config := svc.Config()
|
config := svc.Config()
|
||||||
keepInTray = config.KeepRunningInTray
|
keepInTray = config.KeepRunningInTray
|
||||||
startHidden = resolveStartHidden(startInTray, keepInTray)
|
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||||
applyTrayBehavior(a, w, keepInTray, false)
|
tray := &trayState{}
|
||||||
|
tray.apply(a, w, keepInTray, false)
|
||||||
// Apply the persisted theme before building content so the window renders in
|
// Apply the persisted theme before building content so the window renders in
|
||||||
// the chosen theme from the first frame rather than flashing the default one.
|
// the chosen theme from the first frame rather than flashing the default one.
|
||||||
applyTheme(a, config.Theme)
|
applyTheme(a, config.Theme)
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc, tray)
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
serveSingleInstance(instanceListener, w)
|
serveSingleInstance(instanceListener, w, tray)
|
||||||
if startHidden {
|
if startHidden {
|
||||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||||
// a misleading metric. Record a separate startup event for the tray path
|
// a misleading metric. Record a separate startup event for the tray path
|
||||||
|
|||||||
+37
-29
@@ -26,7 +26,7 @@ var settingsCaptions = []string{
|
|||||||
"GoSentry", "Go", "Fyne", "Repository",
|
"GoSentry", "Go", "Fyne", "Repository",
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
|
||||||
// saved mirrors the config as last persisted (or freshly loaded at
|
// saved mirrors the config as last persisted (or freshly loaded at
|
||||||
// construction); it is a local copy the closures below compare the form
|
// construction); it is a local copy the closures below compare the form
|
||||||
// against and reassign after a successful save, rather than holding onto
|
// against and reassign after a successful save, rather than holding onto
|
||||||
@@ -49,17 +49,36 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
trayRestartHint := widget.NewLabel("")
|
trayRestartHint := widget.NewLabel("")
|
||||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||||
|
// autostartCheckGen guards against an in-flight check's result landing after
|
||||||
|
// a newer one started (e.g. the user toggles a checkbox again before the
|
||||||
|
// first check's PowerShell call returns). Both the increment and the compare
|
||||||
|
// happen on the main/Fyne thread, so this needs no lock of its own.
|
||||||
|
var autostartCheckGen int
|
||||||
refreshAutostartStatus := func() {
|
refreshAutostartStatus := func() {
|
||||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, message := svc.AutostartStatus()
|
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
||||||
if ok {
|
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
||||||
autostartStatus.SetText("OK: " + message)
|
// of cold start. Running it off the main thread keeps that from freezing
|
||||||
return
|
// the window on construction and on every checkbox toggle.
|
||||||
}
|
autostartStatus.SetText("Checking...")
|
||||||
autostartStatus.SetText("Problem: " + message)
|
autostartCheckGen++
|
||||||
|
gen := autostartCheckGen
|
||||||
|
go func() {
|
||||||
|
ok, message := svc.AutostartStatus()
|
||||||
|
fyne.Do(func() {
|
||||||
|
if gen != autostartCheckGen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
autostartStatus.SetText("OK: " + message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autostartStatus.SetText("Problem: " + message)
|
||||||
|
})
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
refreshTrayRestartHint := func(pending bool) {
|
refreshTrayRestartHint := func(pending bool) {
|
||||||
if pending {
|
if pending {
|
||||||
@@ -142,27 +161,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
settingsStatus := widget.NewLabel("")
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
// Only the parse itself happens here: a numeric field has to become an int
|
||||||
if err != nil || files < 0 {
|
// before it can go into a domain.Config at all. Everything else — required
|
||||||
settingsStatus.SetText("Max log files must be zero (unlimited) or a positive number")
|
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||||
return
|
// job (see app.validateConfig), so its error is what the user sees rather
|
||||||
}
|
// than a second copy of the same rules with different wording.
|
||||||
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||||
if err != nil || days < 0 {
|
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||||
settingsStatus.SetText("Max log age days must be zero (unlimited) or a positive number")
|
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||||
return
|
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||||
}
|
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||||
if strings.TrimSpace(jobsFile.Text) == "" {
|
|
||||||
settingsStatus.SetText("Jobs file is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(logsDir.Text) == "" {
|
|
||||||
settingsStatus.SetText("Logs directory is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
|
||||||
if err != nil || timeout < 0 {
|
|
||||||
settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Build the new config from the form and hand it to the Service, which
|
// Build the new config from the form and hand it to the Service, which
|
||||||
@@ -196,7 +204,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||||
if previousKeepInTray != config.KeepRunningInTray {
|
if previousKeepInTray != config.KeepRunningInTray {
|
||||||
trayRestartHint.SetText(trayRestartHintText)
|
trayRestartHint.SetText(trayRestartHintText)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
|
|||||||
// If the port is unavailable but does not answer as GoSentry, continue
|
// If the port is unavailable but does not answer as GoSentry, continue
|
||||||
// startup instead of making the application impossible to open because of an
|
// startup instead of making the application impossible to open because of an
|
||||||
// unrelated local listener. In the normal duplicate-start case the dial above
|
// unrelated local listener. In the normal duplicate-start case the dial above
|
||||||
// succeeds and this process exits after waking the first instance.
|
// succeeds and this process exits after waking the first instance. The
|
||||||
|
// consequence of this fallback — two schedulers able to run against the same
|
||||||
|
// jobs.json and logs directory — is recorded in STANDARDS.md alongside the
|
||||||
|
// unauthenticated nature of this same port.
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
|||||||
// Accept runs on its own goroutine, so focusing the window must be
|
// Accept runs on its own goroutine, so focusing the window must be
|
||||||
// marshaled onto the main thread like every other widget update.
|
// marshaled onto the main thread like every other widget update.
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
mainWindowHidden = false
|
tray.hidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
})
|
})
|
||||||
|
|||||||
+28
-25
@@ -10,16 +10,19 @@ import (
|
|||||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// systemTrayRegistered tracks whether this process registered a tray icon at
|
// trayState tracks the two pieces of tray-related process state that Fyne
|
||||||
// launch. Fyne cannot add or remove the icon mid-session, so toggling
|
// itself does not expose: whether this process has registered the tray icon
|
||||||
// KeepRunningInTray in Settings updates close behavior immediately and shows a
|
// (Fyne cannot add or remove it mid-session, so toggling KeepRunningInTray in
|
||||||
// restart hint for the icon itself.
|
// Settings updates close behavior immediately but shows a restart hint for the
|
||||||
var systemTrayRegistered bool
|
// icon itself) and whether the primary window is currently hidden via the tray
|
||||||
|
// close intercept (Fyne exposes no Window.Visible API). Run owns one instance
|
||||||
// mainWindowHidden tracks whether the primary window was hidden via the tray
|
// and passes it to every call site of apply — settingsView's Save handler is
|
||||||
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the
|
// the other one — so the coupling between them is explicit instead of hidden
|
||||||
// reveal-on-tray-disable path in applyTrayBehavior.
|
// behind package-level globals that no test can reset.
|
||||||
var mainWindowHidden bool
|
type trayState struct {
|
||||||
|
registered bool
|
||||||
|
hidden bool
|
||||||
|
}
|
||||||
|
|
||||||
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
|
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
|
||||||
|
|
||||||
@@ -27,23 +30,23 @@ func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
|||||||
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyTrayBehavior configures window close handling for KeepRunningInTray.
|
// apply configures window close handling for KeepRunningInTray. When
|
||||||
// When revealIfHidden is true and the tray is off, a hidden window is shown so
|
// revealIfHidden is true and the tray is off, a hidden window is shown so the
|
||||||
// the user can still reach the app after disabling the tray mid-session.
|
// user can still reach the app after disabling the tray mid-session.
|
||||||
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
func (t *trayState) apply(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||||
if keepInTray && !systemTrayRegistered {
|
if keepInTray && !t.registered {
|
||||||
registerSystemTray(a, w)
|
t.registerSystemTray(a, w)
|
||||||
systemTrayRegistered = true
|
t.registered = true
|
||||||
}
|
}
|
||||||
setWindowCloseBehavior(w, keepInTray)
|
t.setWindowCloseBehavior(w, keepInTray)
|
||||||
if !keepInTray && revealIfHidden && mainWindowHidden {
|
if !keepInTray && revealIfHidden && t.hidden {
|
||||||
mainWindowHidden = false
|
t.hidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerSystemTray(a fyne.App, w fyne.Window) {
|
func (t *trayState) registerSystemTray(a fyne.App, w fyne.Window) {
|
||||||
desk, ok := a.(fynedesktop.App)
|
desk, ok := a.(fynedesktop.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||||
@@ -74,7 +77,7 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
quit.IsQuit = true
|
quit.IsQuit = true
|
||||||
menu := fyne.NewMenu("GoSentry",
|
menu := fyne.NewMenu("GoSentry",
|
||||||
fyne.NewMenuItem("Show", func() {
|
fyne.NewMenuItem("Show", func() {
|
||||||
mainWindowHidden = false
|
t.hidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
}),
|
}),
|
||||||
@@ -85,17 +88,17 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
desk.SetSystemTrayWindow(w)
|
desk.SetSystemTrayWindow(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||||
if keepInTray {
|
if keepInTray {
|
||||||
w.SetCloseIntercept(func() {
|
w.SetCloseIntercept(func() {
|
||||||
// Closing hides the window instead of quitting because scheduler tools are
|
// Closing hides the window instead of quitting because scheduler tools are
|
||||||
// expected to keep working in the background. The explicit Quit tray item
|
// expected to keep working in the background. The explicit Quit tray item
|
||||||
// remains the way to stop the process.
|
// remains the way to stop the process.
|
||||||
mainWindowHidden = true
|
t.hidden = true
|
||||||
w.Hide()
|
w.Hide()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mainWindowHidden = false
|
t.hidden = false
|
||||||
w.SetCloseIntercept(nil)
|
w.SetCloseIntercept(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user