Compare commits
7 Commits
da8fe10365
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b230c6bdf1 | |||
| 51350d476c | |||
| 565728032c | |||
| 0e6b3bcecf | |||
| 6a03ea4a20 | |||
| 4fb9bf6ff3 | |||
| 9b7a0565ba |
@@ -14,8 +14,11 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><img src="images/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
|
<td align="center"><img src="docs/screenshots/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
|
||||||
<td align="center"><img src="images/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
|
<td align="center"><img src="docs/screenshots/screenshot_history.PNG" alt="History tab"><br><em>History tab — past runs with trigger, state, and log file.</em></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" colspan="2"><img src="docs/screenshots/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ import (
|
|||||||
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
|
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
|
||||||
// image, which the OS then scales to every size it needs — titlebar (~16px),
|
// image, which the OS then scales to every size it needs — titlebar (~16px),
|
||||||
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
|
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
|
||||||
// downscaling the 1254px gosentry-icon-big.png to 16px is muddy, and upscaling
|
// downscaling the 1254px gosentry-icon-large.png to 16px is muddy, and upscaling
|
||||||
// the 16px icon to 32px is blurry. The fix is to feed each surface a
|
// the 16px icon to 32px is blurry. The fix is to feed each surface a
|
||||||
// size-appropriate source — which differs per platform because each platform
|
// size-appropriate source — which differs per platform because each platform
|
||||||
// exposes different icon channels.
|
// exposes different icon channels.
|
||||||
|
|||||||
+48
-9
@@ -41,7 +41,7 @@ flowchart LR
|
|||||||
|
|
||||||
user -->|"edits jobs, settings, runs commands"| ui
|
user -->|"edits jobs, settings, runs commands"| ui
|
||||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
||||||
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
|
svc -->|"OpenStore, PrepareSaveJobs, PrepareSaveConfig, LoadJobsFile"| store
|
||||||
store -->|"read/write"| config
|
store -->|"read/write"| config
|
||||||
store -->|"read/write"| jobs
|
store -->|"read/write"| jobs
|
||||||
|
|
||||||
@@ -119,10 +119,13 @@ example window-maximized detection, which would need per-OS native calls).
|
|||||||
## Main Flows
|
## Main Flows
|
||||||
|
|
||||||
1. Startup:
|
1. Startup:
|
||||||
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
`cmd/gosentry` calls `ui.Run`, which owns the process lifecycle: it calls
|
||||||
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
|
`app.Open()` to open the store, load `gosentry.json` and `jobs.json`, and
|
||||||
events, builds the main window, and calls `Service.Start` to begin the
|
build the `app.Service`, then hands that Service to `newMainView`
|
||||||
scheduler loop. On every launch the service seeds per-job run-time statistics
|
(`mainwindow.go`), which subscribes the UI to service events and calls
|
||||||
|
`Service.Start` to begin the scheduler loop before assembling the tabs.
|
||||||
|
`Run` shows the window and, on quit, calls `Service.Stop`.
|
||||||
|
On every launch the service seeds per-job run-time statistics
|
||||||
from existing log files so the details panel reflects accumulated history
|
from existing log files so the details panel reflects accumulated history
|
||||||
immediately (see §Statistics below).
|
immediately (see §Statistics below).
|
||||||
|
|
||||||
@@ -239,6 +242,7 @@ applies to them — and so measure launch latency only.
|
|||||||
| `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, 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 |
|
| `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 |
|
||||||
|
| `TimedRunCount` | runs that carried a duration, and so contributed to the aggregates above; a legacy log without a `duration` header counts toward `RunCount` but not this |
|
||||||
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
|
| `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
|
||||||
@@ -270,8 +274,9 @@ the moment the window opens.
|
|||||||
|
|
||||||
### `jobs_view.go` file structure
|
### `jobs_view.go` file structure
|
||||||
|
|
||||||
The size guideline for a file in this project is ~250 lines.
|
The size guideline for a file in this project is ~250 lines; up to 20% over
|
||||||
`src/ui/jobs_view.go` is split across five files along these seams:
|
(~300 lines) is acceptable. `src/ui/jobs_view.go` is split across six files
|
||||||
|
along these seams:
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
@@ -295,11 +300,45 @@ list's highlight at the selected job.
|
|||||||
|
|
||||||
### `settings_view.go` file structure
|
### `settings_view.go` file structure
|
||||||
|
|
||||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
`src/ui/settings_view.go` is split across four files the same way, once its
|
||||||
own size passed the guideline:
|
own size passed the guideline:
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `settings_view.go` | `settingsView` — field construction, save, load, validate; the Theme label translation helpers |
|
| `settings_view.go` | `settingsView` — thin entry point; theme label translation helpers |
|
||||||
|
| `settings_view_form.go` | `buildSettingsForm` — widget construction, save, load, cancel, and defaults handlers |
|
||||||
| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row |
|
| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row |
|
||||||
| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) |
|
| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) |
|
||||||
|
|
||||||
|
### `operations.go` file structure
|
||||||
|
|
||||||
|
`src/app/operations.go` is split across four files along the public API,
|
||||||
|
locked helpers, and pure validation seams:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `operations.go` | Job mutators (`CreateJob` … `SetEnabled`); shared constants (`maxJobLogs`, `timestampLayout`, `errJobNotFound`) |
|
||||||
|
| `operations_settings.go` | Config mutators — `SetGlobalPause`, `SetJobListView`, `ShouldNotifyOnFailure`, `UpdateSettings` |
|
||||||
|
| `operations_locked.go` | `*Locked` state helpers (`refreshNextRunLocked` … `nextIDLocked`); `prependLog`, `uiRecord` |
|
||||||
|
| `operations_validate.go` | Pure validators and normalizers — `normalizeJob`, `validateJob`, `hasFileName`, `validateConfig` |
|
||||||
|
|
||||||
|
### `store.go` file structure
|
||||||
|
|
||||||
|
`src/storage/store.go` is split across three files. Path resolution for the
|
||||||
|
executable directory lives in `paths.go`; config-relative path resolution stays
|
||||||
|
with the store API:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `store.go` | `Store` struct, `OpenStore`, `PeekKeepRunningInTray`, save API, `ResolveConfiguredPath`, `applyConfigPaths`, atomic JSON writes |
|
||||||
|
| `store_config.go` | `loadOrCreateConfig` — config load, defaults, and migration shims |
|
||||||
|
| `store_jobs.go` | `LoadJobsFile`, `loadOrCreateJobs`, `normalizeJobs`, sample jobs, platform-specific demo commands |
|
||||||
|
|
||||||
|
### `history_view.go` file structure
|
||||||
|
|
||||||
|
`src/ui/history_view.go` is split across two files:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `history_view_columns.go` | Pure column-width helpers — `textWidth` through `historyColumnWidths`, sample sets, `historyContentValues` |
|
||||||
|
| `history_view.go` | `historyLog`, `historyHeader`, `newHistoryView`, cell text, `newEvent`, `logFileName` |
|
||||||
|
|||||||
+151
-39
@@ -2,25 +2,42 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
## 1.0.2 - 2026-08-05
|
## 1.0.4 - 2026-08-07
|
||||||
|
|
||||||
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
|
**The remaining over-the-guideline source files are split; the roadmap is
|
||||||
the app icon (experimental).**
|
restructured around what actually blocks each item.**
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- **`ROADMAP.md`** — reorganized into Features, Platform, Fyne/UI, and
|
||||||
|
Maintenance. The over-the-guideline file-split item is closed; each deferred
|
||||||
|
UI item now states whether it is blocked on a missing Fyne API, on UX
|
||||||
|
(shippable today but deferred), or on maintenance timing.
|
||||||
|
- **`ARCHITECTURE.md`** — documents the new file layouts for `operations.go`,
|
||||||
|
`store.go`, `history_view.go`, and `settings_view.go`; the size guideline
|
||||||
|
now states the 20% tolerance explicitly; `settings_view` is recorded as
|
||||||
|
four files, not three.
|
||||||
|
|
||||||
|
**Internal:**
|
||||||
|
|
||||||
|
- Four source files over the ~300-line ceiling (250 + 20%) were split in one
|
||||||
|
pass: `operations.go` into job mutators, config mutators
|
||||||
|
(`operations_settings.go`), `*Locked` helpers (`operations_locked.go`), and
|
||||||
|
pure validators (`operations_validate.go`); `store.go` into the store API,
|
||||||
|
config load (`store_config.go`), and jobs load (`store_jobs.go`);
|
||||||
|
`history_view.go` into column-width helpers (`history_view_columns.go`) and
|
||||||
|
the table widget; and `settings_view.go` into a thin entry point plus
|
||||||
|
`settings_view_form.go` for field construction and save/load handlers.
|
||||||
|
Mechanical moves only — every file is now within the ceiling. `run.go` and
|
||||||
|
`service.go` stay as-is.
|
||||||
|
|
||||||
|
## 1.0.3 - 2026-08-07
|
||||||
|
|
||||||
|
**The findings of a whole-project review: durable JSON and log writes, bounded
|
||||||
|
History and overlap queues, and a Jobs selection that follows the job.**
|
||||||
|
|
||||||
**Application:**
|
**Application:**
|
||||||
|
|
||||||
- **Keep running in the system tray** now controls behaviour: with the tray on
|
|
||||||
(default), closing the window hides it and autostart uses `--start-in-tray`;
|
|
||||||
with the tray off, closing quits the app and autostart opens the main window.
|
|
||||||
- Saving a tray change updates close behaviour and the autostart entry
|
|
||||||
immediately. The notification-area icon follows the saved value after a
|
|
||||||
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
|
|
||||||
remove the icon mid-session).
|
|
||||||
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
|
||||||
the window when the tray setting is off — saved config wins over the CLI flag.
|
|
||||||
- On Windows, failure toasts can show the app icon: after `NewWindow`,
|
|
||||||
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
|
|
||||||
`SetIcon`, which would override the PE multi-size window/taskbar icon.
|
|
||||||
- Fixed a Windows quoting bug where a job whose **Command** field held a whole
|
- Fixed a Windows quoting bug where a job whose **Command** field held a whole
|
||||||
command line (a `.bat`/`.cmd` wrapper followed by an argument that itself
|
command line (a `.bat`/`.cmd` wrapper followed by an argument that itself
|
||||||
ended in `.exe`) had its entire command line mistaken for the program path,
|
ended in `.exe`) had its entire command line mistaken for the program path,
|
||||||
@@ -51,17 +68,132 @@ the app icon (experimental).**
|
|||||||
gets slower the longer the app has been running. Measured on 5000 accumulated
|
gets slower the longer the app has been running. Measured on 5000 accumulated
|
||||||
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
|
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
|
||||||
the width rescan alone accounted for 1.5 ms of every redraw.
|
the width rescan alone accounted for 1.5 ms of every redraw.
|
||||||
|
- Two runs of the same job that start within the same second no longer share a
|
||||||
|
log file name. The later one gets a `-2`, `-3`, … suffix instead of silently
|
||||||
|
overwriting the earlier one's log — reachable with a fast manual re-run or a
|
||||||
|
sub-second queue drain.
|
||||||
|
- A hand-edited `jobs.json` in which two entries carry the same `id` no longer
|
||||||
|
leaves them sharing one runtime, one parsed schedule, and one statistics
|
||||||
|
bucket; the duplicate is reassigned a free ID on load, as an absent ID always
|
||||||
|
was.
|
||||||
|
- The **average run duration** shown in Statistics is now the exact sum divided
|
||||||
|
by the timed-run count rather than an incrementally folded integer mean. The
|
||||||
|
old form truncated on every run and the error compounded over a job's life,
|
||||||
|
so the live figure drifted away from the one rebuilt from log files after a
|
||||||
|
restart.
|
||||||
|
- On Linux, a failure to install the `.desktop` file or icon is now reported in
|
||||||
|
History instead of being discarded, so the visible symptom — a generic dock
|
||||||
|
icon — has an explanation.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
|
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
|
||||||
different jobs file in Settings replaces the whole job list; the details pane
|
different jobs file in Settings replaces the whole job list; the details pane
|
||||||
then described whichever job happened to land on the previously selected row —
|
then described whichever job happened to land on the previously selected row —
|
||||||
or went blank if the new list was shorter — while the highlight in the list
|
or went blank if the new list was shorter — while the highlight in the list
|
||||||
stayed where it was. The selection now follows the job itself, and the
|
stayed where it was. The selection now follows the job itself, and the
|
||||||
highlight and the details pane always describe the same one.
|
highlight and the details pane always describe the same one.
|
||||||
|
- Switching the **Folder** filter now keeps the current selection when the new
|
||||||
|
filter still shows that job, instead of always jumping to the folder's first
|
||||||
|
job.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
- **Max log files and max log age days now accept 0, meaning "keep
|
- **Max log files and max log age days now accept 0, meaning "keep
|
||||||
everything."** Log cleanup already supported disabling either policy; the
|
everything."** Log cleanup already supported disabling either policy; the
|
||||||
Settings form and the Service validator rejected the value that would have
|
Settings form and the Service validator rejected the value that would have
|
||||||
turned it on. A config that already set either to 0 is no longer silently
|
turned it on. A config that already set either to 0 is no longer silently
|
||||||
rewritten back to the 100/30 defaults on load.
|
rewritten back to the 100/30 defaults on load.
|
||||||
|
- Opening the tab and saving no longer block the window while the autostart
|
||||||
|
status is read — on Windows that check shells out to PowerShell, and it now
|
||||||
|
runs off the UI thread.
|
||||||
|
- Two spellings of the same absolute **Jobs file** path (mixed separators, a
|
||||||
|
trailing separator) no longer read as a change of file, so saving no longer
|
||||||
|
triggers a spurious reload of the file already in use.
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- Documentation audited against the code. `ARCHITECTURE.md` — the `jobs_view.go`
|
||||||
|
split is six files, not five (the state extraction was never counted), the
|
||||||
|
statistics table lists `TimedRunCount`, the store edge of the diagram names
|
||||||
|
the methods that exist, and startup says where `Service.Start` is actually
|
||||||
|
called. `TESTS.md` — three tests that had no entry are described
|
||||||
|
(`TestLoadOrCreateConfigPreservesZeroRetentionLimits`,
|
||||||
|
`TestWriteJSONReplacesFileAtomically`,
|
||||||
|
`TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension`), the
|
||||||
|
deliberately-uncovered list covers everything the profile reports at 0%, and
|
||||||
|
the coverage figure records how to read the total rather than the per-package
|
||||||
|
lines. `ROADMAP.md` — the over-the-guideline table was re-measured.
|
||||||
|
- README's scheduler wording caught up with the 0.11.2 rename of "Pause all" to
|
||||||
|
**Disable auto**, and its notification description matches what the app sends.
|
||||||
|
- `STANDARDS.md` records the rules the review settled: no file I/O under
|
||||||
|
`Service.mu`, the History and pending-run caps, the zero-retention meaning,
|
||||||
|
that a start-only process outlives GoSentry, the single-instance fallback's
|
||||||
|
consequence, and the unauthenticated instance-channel port.
|
||||||
|
- `docs/REVIEW.md` (the whole-project review agenda) and the working plan it
|
||||||
|
produced are retired now that every item is either landed here or recorded in
|
||||||
|
`ROADMAP.md`, the way the test review plan was in 1.0.1. `STANDARDS.md` is the
|
||||||
|
surviving reference.
|
||||||
|
- The screenshots moved to `docs/screenshots/`.
|
||||||
|
|
||||||
|
**Internal:**
|
||||||
|
|
||||||
|
- The failure-notification timing diagnostic added in 1.0.2 is now written to
|
||||||
|
`logs/notify-timing.tsv`. The `.tsv` extension keeps it out of `CleanupLogs`,
|
||||||
|
which manages only `.log` files, so it is neither deleted by age nor counted
|
||||||
|
against **Max log files**, and the append now runs off the UI thread.
|
||||||
|
- `jobs.json` is no longer rewritten twice per run. Starting and finishing a run
|
||||||
|
touch only `JobRuntime`, which is never persisted, so both saves re-serialised
|
||||||
|
identical bytes; `SetGlobalPause` did the same alongside its real `SaveConfig`.
|
||||||
|
Removing them also removes the run-start rollback path and the save failure it
|
||||||
|
reported, so `RunDue` no longer has a start error to surface at all.
|
||||||
|
- File I/O no longer happens while `Service.mu` is held — that is the lock the
|
||||||
|
UI thread takes on every job and runtime read, so a JSON write, the
|
||||||
|
post-run log cleanup, or the startup log scan used to make a UI refresh wait
|
||||||
|
on the disk. Saves are now prepared under the lock and written after it is
|
||||||
|
released, in preparation order, so `jobs.json` still ends up matching the
|
||||||
|
in-memory list. Seeding statistics from logs also opens each log file once
|
||||||
|
instead of twice.
|
||||||
|
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
|
||||||
|
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
|
||||||
|
`jobs_view_list.go`, and `jobs_view_toolbar.go`, joining the existing
|
||||||
|
`jobs_view_details.go` and `jobs_view_helpers.go`. What used to be one
|
||||||
|
330-line constructor whose dozen closures shared seven mutable locals is now
|
||||||
|
widgets reading one named state object — which is what made the selection fix
|
||||||
|
above a change in one place instead of five.
|
||||||
|
- `Service.Store()` is replaced by typed `Service.Config()` and `Service.Paths()`
|
||||||
|
accessors that copy under the lock, so the UI no longer reaches into a shared
|
||||||
|
`*storage.Store`. The Jobs pause control is now driven by `refreshView`
|
||||||
|
reading `svc.Config().Paused` on every event, making it a real consumer of
|
||||||
|
`SchedulerStateChanged`, and the main window's event listener is a type switch.
|
||||||
|
- Dead code removed: `collectActivity`, the `yaml` tags on `RunRecord`, the
|
||||||
|
`logArguments`/`LogArguments` alias, the redundant package-level
|
||||||
|
`SetAutostart`/`AutostartStatus` functions, and the Settings Save handler's
|
||||||
|
second copy of the Service's validation rules. The
|
||||||
|
`systemTrayRegistered`/`mainWindowHidden` globals are one `trayState` value
|
||||||
|
that `Run` owns and threads through.
|
||||||
|
- `scripts/test.bat` no longer prints mojibake for its checkmarks under a
|
||||||
|
non-UTF-8 code page.
|
||||||
|
|
||||||
|
## 1.0.2 - 2026-08-05
|
||||||
|
|
||||||
|
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
|
||||||
|
the app icon (experimental).**
|
||||||
|
|
||||||
|
**Application:**
|
||||||
|
|
||||||
|
- **Keep running in the system tray** now controls behaviour: with the tray on
|
||||||
|
(default), closing the window hides it and autostart uses `--start-in-tray`;
|
||||||
|
with the tray off, closing quits the app and autostart opens the main window.
|
||||||
|
- Saving a tray change updates close behaviour and the autostart entry
|
||||||
|
immediately. The notification-area icon follows the saved value after a
|
||||||
|
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
|
||||||
|
remove the icon mid-session).
|
||||||
|
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
||||||
|
the window when the tray setting is off — saved config wins over the CLI flag.
|
||||||
|
- On Windows, failure toasts can show the app icon: after `NewWindow`,
|
||||||
|
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
|
||||||
|
`SetIcon`, which would override the PE multi-size window/taskbar icon.
|
||||||
|
|
||||||
**Jobs:**
|
**Jobs:**
|
||||||
|
|
||||||
@@ -77,30 +209,10 @@ the app icon (experimental).**
|
|||||||
|
|
||||||
**Internal:**
|
**Internal:**
|
||||||
|
|
||||||
- App-side failure-notification timing is appended to `logs/notify-timing.tsv`
|
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
||||||
for diagnosing toast delay (OS latency excluded). The `.tsv` extension keeps
|
for diagnosing toast delay (OS latency excluded).
|
||||||
the diagnostic file out of `CleanupLogs`, which manages only `.log` files, so
|
`scripts/measure-windows-toast.ps1` measures the PowerShell baseline on
|
||||||
it is neither deleted by age nor counted against **Max log files**. The append
|
Windows.
|
||||||
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
|
|
||||||
UI thread takes on every job and runtime read, so a JSON write, the
|
|
||||||
post-run log cleanup, or the startup log scan used to make a UI refresh wait
|
|
||||||
on the disk. Saves are now prepared under the lock and written after it is
|
|
||||||
released, in preparation order, so `jobs.json` still ends up matching the
|
|
||||||
in-memory list. Seeding statistics from logs also opens each log file once
|
|
||||||
instead of twice.
|
|
||||||
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
|
|
||||||
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
|
|
||||||
`jobs_view_list.go`, and `jobs_view_toolbar.go`. What used to be one 330-line
|
|
||||||
constructor whose dozen closures shared seven mutable locals is now widgets
|
|
||||||
reading one named state object — which is what made the selection fix above a
|
|
||||||
change in one place instead of five.
|
|
||||||
|
|
||||||
## 1.0.1 - 2026-08-04
|
## 1.0.1 - 2026-08-04
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -280,8 +280,9 @@ Before tagging:
|
|||||||
|
|
||||||
1. Bump `src/app/version.go`. The tag must match it exactly.
|
1. Bump `src/app/version.go`. The tag must match it exactly.
|
||||||
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
||||||
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
|
3. Retake the README screenshots (`docs/screenshots/screenshot_jobs.PNG`,
|
||||||
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
|
`docs/screenshots/screenshot_settings.PNG`,
|
||||||
|
`docs/screenshots/screenshot_history.PNG`) if the GUI changed its appearance. This is
|
||||||
easy to forget because nothing fails without it: `README.md` is packaged
|
easy to forget because nothing fails without it: `README.md` is packaged
|
||||||
inside every release archive and is what the forge shows on the project page,
|
inside every release archive and is what the forge shows on the project page,
|
||||||
so a stale shot advertises an application that no longer exists. Take them
|
so a stale shot advertises an application that no longer exists. Take them
|
||||||
|
|||||||
+96
-106
@@ -3,65 +3,9 @@
|
|||||||
This file tracks planned GoSentry work that is larger than a single bug fix.
|
This file tracks planned GoSentry work that is larger than a single bug fix.
|
||||||
Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
||||||
|
|
||||||
## Open Items
|
## Features
|
||||||
|
|
||||||
### Faster Windows failure notifications
|
User-facing functionality that is not blocked on a framework or platform gap.
|
||||||
|
|
||||||
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
|
|
||||||
writes a short script to `%TEMP%` and runs it through a **new PowerShell
|
|
||||||
process** (`app/app_windows.go`), which typically adds **1–3 seconds** of cold
|
|
||||||
start before the toast appears. GoSentry's own path from run completion through
|
|
||||||
`SendNotification` is much smaller and is logged separately.
|
|
||||||
|
|
||||||
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
|
|
||||||
machine):** average **773 ms** per toast (695–874 ms), dominated by PowerShell
|
|
||||||
cold start. Re-run the script when comparing after a native toast implementation.
|
|
||||||
|
|
||||||
**App-side timing:** each failure notification appends one line to
|
|
||||||
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
|
||||||
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
|
||||||
toast latency is not included. The `.tsv` extension keeps it out of
|
|
||||||
`runner.CleanupLogs`, which only manages `.log` files — this file is
|
|
||||||
diagnostic instrumentation for this item, not job output, and should be
|
|
||||||
removed (or unified with the run-log retention policy under its own knob) once
|
|
||||||
the native-toast direction below lands and the timing data is no longer
|
|
||||||
needed.
|
|
||||||
|
|
||||||
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
|
||||||
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
|
||||||
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
|
||||||
needs the same treatment.
|
|
||||||
|
|
||||||
### Retire the config compatibility shims
|
|
||||||
|
|
||||||
Two read-only shims in `storage.loadOrCreateConfig` rewrite an old file into
|
|
||||||
the current shape on the next save, so each becomes dead the moment a user's
|
|
||||||
config has been saved once by a build that has it:
|
|
||||||
|
|
||||||
- `Config.JobsDir` (pre-0.15, superseded by `Config.JobsFile`).
|
|
||||||
- `Theme == "default"` (pre-1.0.1, superseded by `ThemeSystem`).
|
|
||||||
|
|
||||||
Neither has an expiry. Remove both — the field, the migration branch, and
|
|
||||||
`TestLoadOrCreateConfigMigratesJobsDir` /
|
|
||||||
`TestLoadOrCreateConfigMigratesLegacyThemeDefault` — once a release has shipped
|
|
||||||
long enough that a config file still carrying either old shape is not a
|
|
||||||
realistic upgrade path GoSentry needs to support.
|
|
||||||
|
|
||||||
### Dynamic tray icon toggle
|
|
||||||
|
|
||||||
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
|
||||||
There is no supported way to register or remove the notification-area icon
|
|
||||||
after the process is running.
|
|
||||||
|
|
||||||
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
|
|
||||||
autostart entry update immediately when the user saves Settings; the tray icon
|
|
||||||
follows the saved value on the next launch. Settings shows a restart hint when
|
|
||||||
the tray checkbox changes.
|
|
||||||
|
|
||||||
Revisit when Fyne adds a documented API for mid-session tray registration, or
|
|
||||||
when a stable cross-platform approach exists without reaching into driver
|
|
||||||
internals. Until then, removing the restart hint and applying the icon on save
|
|
||||||
is blocked.
|
|
||||||
|
|
||||||
### Update check from GitHub releases
|
### Update check from GitHub releases
|
||||||
|
|
||||||
@@ -139,62 +83,97 @@ Design notes / open questions:
|
|||||||
Service exposes import/export operations; the UI only picks the file and
|
Service exposes import/export operations; the UI only picks the file and
|
||||||
shows the outcome.
|
shows the outcome.
|
||||||
|
|
||||||
### Split the files that are over the size guideline
|
## Platform
|
||||||
|
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
OS-specific improvements outside the Fyne abstraction.
|
||||||
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
|
||||||
examples. `jobs_view.go` was split again in 1.0.2 — into view, state, list, and
|
|
||||||
toolbar — because the selection defect it carried was a symptom of the size
|
|
||||||
(one 330-line constructor over seven shared locals). Five non-test files are
|
|
||||||
over the guideline as of that pass:
|
|
||||||
|
|
||||||
| File | Lines |
|
### Faster Windows failure notifications
|
||||||
|------|-------|
|
|
||||||
| `src/app/operations.go` | 529 |
|
|
||||||
| `src/ui/history_view.go` | 373 |
|
|
||||||
| `src/storage/store.go` | 365 |
|
|
||||||
| `src/ui/settings_view.go` | 318 |
|
|
||||||
| `src/app/run.go` | 274 |
|
|
||||||
|
|
||||||
The remaining five are deliberately deferred rather than done piecemeal: a
|
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
|
||||||
split touches every reader of the file, and doing them in one pass keeps the
|
writes a short script to `%TEMP%` and runs it through a **new PowerShell
|
||||||
seams consistent instead of settling them five different ways. Splitting is
|
process** (`app/app_windows.go`), which typically adds **1–3 seconds** of cold
|
||||||
also the kind of change that reads as pure movement while quietly dropping a
|
start before the toast appears. GoSentry's own path from run completion through
|
||||||
function, so it wants one careful pass, not five hurried ones.
|
`SendNotification` is much smaller and is logged separately.
|
||||||
|
|
||||||
Seams visible today, as a starting point rather than a decision:
|
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
|
||||||
|
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.
|
||||||
|
|
||||||
- **`operations.go`** — the worst overage and the clearest split: the public
|
**App-side timing:** each failure notification appends one line to
|
||||||
mutating operations (`CreateJob` … `UpdateSettings`), the `…Locked` state
|
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
||||||
helpers that only they call, and the pure validators and normalizers
|
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
||||||
(`normalizeJob`, `validateJob`, `hasFileName`, `validateConfig`) are three
|
toast latency is not included. The `.tsv` extension keeps it out of
|
||||||
distinct jobs already sitting in three consecutive blocks.
|
`runner.CleanupLogs`, which only manages `.log` files — this file is
|
||||||
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
diagnostic instrumentation for this item, not job output, and should be
|
||||||
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
removed (or unified with the run-log retention policy under its own knob) once
|
||||||
table they size.
|
the native-toast direction below lands and the timing data is no longer
|
||||||
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
|
needed.
|
||||||
load/normalize path are three separate concerns in one file.
|
|
||||||
- **`run.go`**, **`settings_view.go`** — barely over. Worth re-measuring at the
|
|
||||||
time; if a pass elsewhere has shrunk them, leave them alone rather than
|
|
||||||
splitting for the sake of the number.
|
|
||||||
|
|
||||||
The `jobs_view.go` pass is the worked example for the rest: the constructor was
|
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
||||||
broken up along the state it shared, not along line count, and the split landed
|
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
||||||
with the selection fix rather than promising it separately.
|
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
||||||
|
needs the same treatment.
|
||||||
|
|
||||||
Scope note: the guideline is about source files. Test files are much larger and
|
## Fyne / UI
|
||||||
that is fine — a table-driven test file grows with the cases it covers.
|
|
||||||
|
GUI-layer gaps and trade-offs. Each item below states its **blocker**:
|
||||||
|
|
||||||
|
- **Fyne API** — the missing API is the root cause. Qt, GTK, and native
|
||||||
|
toolkits usually expose the same capability; a future Fyne release is the
|
||||||
|
preferred fix. Some items have no viable workaround; others could be bypassed
|
||||||
|
with `src/platform/` code, but that cost is not justified while upstream might
|
||||||
|
still add the API.
|
||||||
|
- **UX** — shippable with today's widgets; deferred until a cleaner approach
|
||||||
|
exists (a nicer Fyne widget would help but is not required).
|
||||||
|
|
||||||
|
### Dynamic tray icon toggle
|
||||||
|
|
||||||
|
**Blocker: Fyne API** (no viable workaround).
|
||||||
|
|
||||||
|
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
||||||
|
There is no supported way to register or remove the notification-area icon
|
||||||
|
after the process is running.
|
||||||
|
|
||||||
|
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
|
||||||
|
autostart entry update immediately when the user saves Settings; the tray icon
|
||||||
|
follows the saved value on the next launch. Settings shows a restart hint when
|
||||||
|
the tray checkbox changes.
|
||||||
|
|
||||||
|
Revisit when Fyne adds a documented API for mid-session tray registration, or
|
||||||
|
when a stable cross-platform approach exists without reaching into driver
|
||||||
|
internals. Until then, removing the restart hint and applying the icon on save
|
||||||
|
is blocked.
|
||||||
|
|
||||||
|
### History tab — column filters (Trigger / Job / State)
|
||||||
|
|
||||||
|
**Blocker: UX** (not a Fyne release).
|
||||||
|
|
||||||
|
Add dropdown filters above the History table so the user can narrow rows by
|
||||||
|
trigger source, job name, or run state. A filter bar built from `widget.Select`
|
||||||
|
widgets above the table would work today — filter the row slice in app code —
|
||||||
|
but `widget.Table` has no built-in filter API and the ad-hoc bar feels visually
|
||||||
|
out-of-place. Revisit when Fyne adds first-class column filtering or a
|
||||||
|
composable data-grid widget, or when a hand-rolled bar is acceptable.
|
||||||
|
|
||||||
### Window size persistence *(frozen)*
|
### Window size persistence *(frozen)*
|
||||||
|
|
||||||
|
**Blocker: Fyne API** (costly platform workaround possible).
|
||||||
|
|
||||||
Window size is currently **not** saved on quit or close. Saving was disabled
|
Window size is currently **not** saved on quit or close. Saving was disabled
|
||||||
because `w.Canvas().Size()` returns the maximized dimensions when the window is
|
because `w.Canvas().Size()` returns the maximized dimensions when the window is
|
||||||
maximized, which would corrupt the stored size on the next launch.
|
maximized, which would corrupt the stored size on the next launch.
|
||||||
|
|
||||||
Re-enabling requires a cross-platform way to detect the maximized state before
|
Fyne v2.x has no API to query window state (maximized, normal, etc.) — unlike
|
||||||
saving. Fyne v2.x has no API for this; it needs per-OS native calls:
|
Qt, GTK, or platform-native toolkits. If Fyne added something like
|
||||||
`IsZoomed` (Windows), `_NET_WM_STATE` (X11/Linux), `NSWindow.isZoomed`
|
`Window.IsMaximized()`, persistence could be re-enabled without any
|
||||||
(macOS). Unfreeze once that detection is in place.
|
platform-specific code; that upstream API is the preferred unblock.
|
||||||
|
|
||||||
|
A bypass through per-OS native detection (`IsZoomed` on Windows,
|
||||||
|
`_NET_WM_STATE` on X11, `NSWindow.isZoomed` on macOS) is technically possible,
|
||||||
|
similar to other `src/platform/` work, but Fyne does not expose the underlying
|
||||||
|
window handle, so the bypass is fragile and expensive. The item stays frozen
|
||||||
|
until either Fyne ships window-state API or that platform cost is judged worth
|
||||||
|
paying.
|
||||||
|
|
||||||
**Disadvantages of a platform-specific approach:**
|
**Disadvantages of a platform-specific approach:**
|
||||||
|
|
||||||
@@ -218,10 +197,21 @@ saving. Fyne v2.x has no API for this; it needs per-OS native calls:
|
|||||||
headless test driver; it requires a real display and manual or screen-capture
|
headless test driver; it requires a real display and manual or screen-capture
|
||||||
automation per platform.
|
automation per platform.
|
||||||
|
|
||||||
### History tab — column filters (Trigger / Job / State)
|
## Maintenance
|
||||||
|
|
||||||
Add dropdown filters above the History table so the user can narrow rows by
|
Technical debt and one-time cleanup with no user-visible feature surface.
|
||||||
trigger source, job name, or run state. Blocked on Fyne native support: the
|
|
||||||
current `widget.Table` has no built-in filter API, and a filter bar built from
|
### Retire the config compatibility shims
|
||||||
`widget.Select` widgets above the table feels visually out-of-place. Revisit
|
|
||||||
when Fyne adds first-class column filtering or a composable data-grid widget.
|
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.
|
||||||
|
|||||||
+29
-9
@@ -66,20 +66,31 @@ exercised from another one's tests — `domain.NewRuntime`, for instance, is
|
|||||||
covered by the `app` tests. Measure the engine packages together instead:
|
covered by the `app` tests. Measure the engine packages together instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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 -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
```
|
```
|
||||||
|
|
||||||
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
||||||
splits the comma-separated `-coverpkg` list on its own and the command fails
|
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
|
with `directory not found`. Use the stop-parsing token, or quote the whole
|
||||||
flag:
|
flag — and note that `--%` swallows the rest of the line, so the profile has to
|
||||||
|
be read by a second command:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
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 --% -coverprofile=cover.out -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
|
The total is the last line of the profile summary. It is **not** any of the
|
||||||
against before concluding that coverage has slipped.
|
per-package lines `go test` prints: with `-coverpkg` spanning five packages,
|
||||||
|
each of those reports only what that one package's tests reached across the
|
||||||
|
whole set, so all five are far below the real figure.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go tool cover -func=cover.out | Select-Object -Last 1
|
||||||
|
```
|
||||||
|
|
||||||
|
That total was 84.4% at the 2026-08-04 review and 84.1% at the 2026-08-07
|
||||||
|
documentation audit — the number to compare against before concluding that
|
||||||
|
coverage has slipped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -238,8 +249,8 @@ Tests display-formatting helpers used by the UI.
|
|||||||
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
||||||
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
|
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
|
||||||
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
|
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
|
||||||
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
|
| `TestDisplayFolder` | Verifies that an empty folder string shows "(No folder)". |
|
||||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
|
| `TestDisplayArguments` | Verifies that an empty arguments string shows "(none)". |
|
||||||
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
||||||
| `TestDisplayInvocation` | Verifies that the full invocation display string combines command and arguments with spacing. |
|
| `TestDisplayInvocation` | Verifies that the full invocation display string combines command and arguments with spacing. |
|
||||||
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
||||||
@@ -265,12 +276,14 @@ Tests JSON round-tripping, default generation, and backward compatibility.
|
|||||||
| `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. |
|
||||||
|
| `TestLoadOrCreateConfigPreservesZeroRetentionLimits` | Verifies that `max_log_files` / `max_log_age_days` of 0 read back as 0 ("keep everything") instead of being backfilled to the 100 / 30 defaults — a field the file sets is not the missing-field case. |
|
||||||
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
||||||
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
|
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
|
||||||
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
||||||
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
||||||
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
||||||
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
||||||
|
| `TestWriteJSONReplacesFileAtomically` | Pins the durability fix: `writeJSON` replaces the destination through a temp file and a rename rather than truncating it in place, and leaves no temp file behind. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -355,6 +368,7 @@ Tests the Windows shell invocation and hidden-window flags.
|
|||||||
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
||||||
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
||||||
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||||
|
| `TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension` | Regression: the program path ends at the *earliest* extension match sitting at a token boundary — not the first extension in `.exe`/`.cmd`/`.bat`/`.com` list order, and not a substring inside another word — so a `.bat` wrapper followed by an `.exe` argument still quotes only the wrapper. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -674,6 +688,12 @@ A coverage run over the non-UI packages reports these as uncovered. All are
|
|||||||
intentional; none is an oversight to be "fixed" with a test.
|
intentional; none is an oversight to be "fixed" with a test.
|
||||||
|
|
||||||
- The real `Clock` — a fake is injected everywhere it is used.
|
- The real `Clock` — a fake is injected everywhere it is used.
|
||||||
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Service.Open` — process entry points, exercised by running the app.
|
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Open` — process entry points, exercised by running the app.
|
||||||
- The autostart and desktop-icon wrappers — OS integration, driven only on a real desktop.
|
- The autostart and desktop-icon wrappers (`app.Service.InstallDesktopIcon`, `AutostartStatus`, `ApplyAutostart`) — OS integration, driven only on a real desktop.
|
||||||
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
|
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
|
||||||
|
- `app.Service.Config` and `app.Service.Paths` — read only from `src/ui`, which
|
||||||
|
this run excludes, so they are covered by the suite but not by this profile.
|
||||||
|
The same applies to `storage.Store.SaveJobs`: the engine writes through
|
||||||
|
`PrepareSaveJobs`, and the one-shot wrapper is what `OpenStore` uses.
|
||||||
|
- The five `isEvent` marker methods in `app/events.go` — empty bodies that exist
|
||||||
|
only to close the `Event` interface.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -3,13 +3,9 @@ package app
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
||||||
@@ -173,357 +169,3 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
|||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetGlobalPause flips the global pause that gates scheduled execution.
|
|
||||||
// Manual "Run now" remains available while paused. Each enabled job's next-run
|
|
||||||
// text reflects the new state immediately so the list view is understandable
|
|
||||||
// before the next tick. A "Paused"/"Resumed" scheduler activity record and a
|
|
||||||
// SchedulerStateChanged event are emitted.
|
|
||||||
func (s *Service) SetGlobalPause(paused bool) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.paused = paused
|
|
||||||
s.store.Config.Paused = paused
|
|
||||||
now := time.Now()
|
|
||||||
for index := range s.jobs {
|
|
||||||
job := &s.jobs[index]
|
|
||||||
runtime := s.runtimeForLocked(job)
|
|
||||||
if paused {
|
|
||||||
// A "queue" backlog counts occurrences missed *while paused is off*; once
|
|
||||||
// paused, none of those correspond to anything the user would expect
|
|
||||||
// replayed on resume, so drop it rather than letting a stale counter fire
|
|
||||||
// a deferred run for an occurrence from before the pause.
|
|
||||||
runtime.PendingRuns = 0
|
|
||||||
}
|
|
||||||
s.refreshNextRunFromLocked(job, runtime, now)
|
|
||||||
}
|
|
||||||
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if err := save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
|
||||||
if paused {
|
|
||||||
state, detail = "Paused", "All job execution paused"
|
|
||||||
}
|
|
||||||
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
|
||||||
s.emit(SchedulerStateChanged{Paused: paused})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetJobListView persists the Jobs list density preference. Unlike
|
|
||||||
// SetGlobalPause this touches nothing but the config: no job changed, so there
|
|
||||||
// is no SaveJobs, and no event is emitted — the choice is presentational and the
|
|
||||||
// Jobs view refreshes its own list, whereas an event would trigger a pointless
|
|
||||||
// whole-window refresh. Anything that is not "compact" is stored as detailed so
|
|
||||||
// the file never gains an unrecognised value.
|
|
||||||
func (s *Service) SetJobListView(view domain.JobListView) error {
|
|
||||||
if !view.IsCompact() {
|
|
||||||
view = domain.JobListViewDetailed
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.store.Config.JobListView == view {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.store.Config.JobListView = view
|
|
||||||
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
|
||||||
s.mu.Unlock()
|
|
||||||
return save()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
|
||||||
// notifications for failed job runs. It reads the config under mu so it is
|
|
||||||
// safe to call from any goroutine.
|
|
||||||
func (s *Service) ShouldNotifyOnFailure() bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.store.Config.NotifyOnFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSettings validates and persists a new application configuration. The
|
|
||||||
// loaded jobs are re-saved because the jobs file may have changed, and log
|
|
||||||
// cleanup runs so a tightened retention policy takes effect immediately.
|
|
||||||
//
|
|
||||||
// Pointing the config at a different jobs file that already exists adopts that
|
|
||||||
// file: its jobs replace the loaded ones, which is the only way the user can
|
|
||||||
// switch between job lists. A path with no file there yet receives the current
|
|
||||||
// jobs instead, which is how the jobs file is renamed or relocated. Adoption
|
|
||||||
// discards all runtime state, so it is refused while a job is running.
|
|
||||||
func (s *Service) UpdateSettings(config domain.Config) error {
|
|
||||||
if err := validateConfig(config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// The path is stored exactly as it is resolved, so a hand-typed value with
|
|
||||||
// stray spaces cannot make the saved setting and the file in use disagree.
|
|
||||||
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
// AppDir is fixed for the process and only UpdateSettings itself — a UI
|
|
||||||
// action — can move JobsPath, so this snapshot stays valid across the reads
|
|
||||||
// below.
|
|
||||||
appDir := s.store.Paths.AppDir
|
|
||||||
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
|
|
||||||
switching := jobsPath != s.store.Paths.JobsPath
|
|
||||||
running := s.anyRunningLocked()
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if switching && running {
|
|
||||||
return errors.New("cannot change the jobs file while a job is running")
|
|
||||||
}
|
|
||||||
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
|
||||||
// new config points at, before anything is written and while no lock is held:
|
|
||||||
// both are file I/O, and SeedStats opens every log in the directory. A file
|
|
||||||
// that cannot be parsed leaves both the config and the current jobs untouched.
|
|
||||||
var adopted []domain.Job
|
|
||||||
var seeds map[int]runner.SeededStats
|
|
||||||
if switching {
|
|
||||||
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
adopted = jobs
|
|
||||||
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
// The guard above was evaluated before the reads, off the lock, so re-check
|
|
||||||
// it: a scheduled run may have started in the meantime, and adoption drops
|
|
||||||
// every runtime.
|
|
||||||
if switching && s.anyRunningLocked() {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return errors.New("cannot change the jobs file while a job is running")
|
|
||||||
}
|
|
||||||
s.store.Config = config
|
|
||||||
saveConfig := s.store.PrepareSaveConfig()
|
|
||||||
if adopted != nil {
|
|
||||||
s.adoptJobsLocked(adopted)
|
|
||||||
s.applySeededStatsLocked(seeds)
|
|
||||||
}
|
|
||||||
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
|
||||||
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
|
||||||
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
|
||||||
// that normalization filled in, exactly as loading them at startup would. The
|
|
||||||
// jobs write is skipped when the config write fails, because both writes run
|
|
||||||
// in the order prepared and stop at the first error.
|
|
||||||
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
|
||||||
loaded := len(s.jobs)
|
|
||||||
logsDir := s.store.Paths.LogsDir
|
|
||||||
maxFiles := s.store.Config.MaxLogFiles
|
|
||||||
maxAge := s.store.Config.MaxLogAgeDays
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
saveErr := save()
|
|
||||||
if adopted != nil {
|
|
||||||
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
|
||||||
// History which file those jobs came from, since nothing was asked. Both
|
|
||||||
// are emitted even when the write failed: the adopted jobs are already the
|
|
||||||
// in-memory list, and a job list the user cannot see would be worse than
|
|
||||||
// the error they are about to be shown.
|
|
||||||
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
|
||||||
s.emit(JobChanged{})
|
|
||||||
}
|
|
||||||
if saveErr != nil {
|
|
||||||
return saveErr
|
|
||||||
}
|
|
||||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
|
||||||
// honoring enabled/paused state. The caller must hold mu.
|
|
||||||
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
|
||||||
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
|
||||||
// time, used when one timestamp should drive a whole batch (e.g. a global
|
|
||||||
// pause). The caller must hold mu.
|
|
||||||
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
|
||||||
if !job.Enabled {
|
|
||||||
runtime.NextRun = "Paused"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.paused {
|
|
||||||
runtime.NextRun = "Scheduler paused"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.prepareNextRunLocked(job, runtime, from)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareNextRunLocked computes the concrete next-due time from the cached
|
|
||||||
// schedule. A missing cache entry means the schedule string was unparseable.
|
|
||||||
// The caller must hold mu.
|
|
||||||
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
|
||||||
sched, ok := s.schedules[job.ID]
|
|
||||||
if !ok {
|
|
||||||
runtime.NextRun = "Invalid schedule"
|
|
||||||
runtime.NextDue = time.Time{}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runtime.NextDue = sched.Next(from)
|
|
||||||
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
|
||||||
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
|
||||||
// two apart. The caller must hold mu.
|
|
||||||
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
|
||||||
sched, err := domain.Parse(job.Schedule)
|
|
||||||
if err != nil {
|
|
||||||
delete(s.schedules, job.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.schedules[job.ID] = sched
|
|
||||||
}
|
|
||||||
|
|
||||||
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
|
||||||
// given ID, or nil. The caller must hold mu.
|
|
||||||
func (s *Service) findByIDLocked(id int) *domain.Job {
|
|
||||||
index := s.indexByIDLocked(id)
|
|
||||||
if index < 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &s.jobs[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
|
||||||
// The caller must hold mu.
|
|
||||||
func (s *Service) indexByIDLocked(id int) int {
|
|
||||||
for index := range s.jobs {
|
|
||||||
if s.jobs[index].ID == id {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
|
||||||
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
|
||||||
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
|
|
||||||
runtime, ok := s.runtimes[job.ID]
|
|
||||||
if !ok || runtime == nil {
|
|
||||||
runtime = domain.NewRuntime(*job)
|
|
||||||
s.runtimes[job.ID] = runtime
|
|
||||||
}
|
|
||||||
return runtime
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
|
||||||
// caller must hold mu.
|
|
||||||
func (s *Service) nextIDLocked() int {
|
|
||||||
next := 1
|
|
||||||
for index := range s.jobs {
|
|
||||||
if s.jobs[index].ID >= next {
|
|
||||||
next = s.jobs[index].ID + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// prependLog adds a record to the front of a runtime's activity list and caps
|
|
||||||
// its length so it cannot grow without bound.
|
|
||||||
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
|
||||||
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
|
||||||
if len(runtime.Logs) > maxJobLogs {
|
|
||||||
runtime.Logs = runtime.Logs[:maxJobLogs]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiRecord builds an activity record for a user/Service action, using the same
|
|
||||||
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
|
||||||
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
|
||||||
return domain.RunRecord{
|
|
||||||
Time: time.Now().Format(timestampLayout),
|
|
||||||
JobID: jobID,
|
|
||||||
JobName: jobName,
|
|
||||||
Trigger: "UI",
|
|
||||||
State: state,
|
|
||||||
Detail: detail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeJob trims user-entered fields and applies the same defaults the job
|
|
||||||
// dialog used, so callers do not have to.
|
|
||||||
func normalizeJob(job *domain.Job) {
|
|
||||||
job.Name = strings.TrimSpace(job.Name)
|
|
||||||
job.Folder = strings.TrimSpace(job.Folder)
|
|
||||||
job.Schedule = strings.TrimSpace(job.Schedule)
|
|
||||||
job.Command = strings.TrimSpace(job.Command)
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateJob enforces the minimum executable definition: name, schedule, and
|
|
||||||
// command must be present. Folder is optional. The schedule string itself is not
|
|
||||||
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
|
||||||
// next-run, matching the prior behavior.
|
|
||||||
func validateJob(job domain.Job) error {
|
|
||||||
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
|
||||||
return errors.New("name, schedule, and command are required")
|
|
||||||
}
|
|
||||||
policy := strings.TrimSpace(job.OverlapPolicy)
|
|
||||||
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
|
||||||
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
|
||||||
}
|
|
||||||
if job.TimeoutSeconds != nil && *job.TimeoutSeconds < 0 {
|
|
||||||
return errors.New("timeout must be zero (no timeout) or a positive number of seconds, or unset to inherit the global default")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasFileName reports whether a path ends in something that can be a file name.
|
|
||||||
// It is a syntax check only — an existing directory whose name looks like a file
|
|
||||||
// name still passes, and fails at write time — but it catches the shapes a user
|
|
||||||
// types when they mean a folder: a trailing separator, "." and "..".
|
|
||||||
func hasFileName(path string) bool {
|
|
||||||
if strings.HasSuffix(path, "/") || strings.HasSuffix(path, string(filepath.Separator)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch filepath.Base(path) {
|
|
||||||
case ".", "..", string(filepath.Separator):
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateConfig rejects settings that would break persistence or cleanup.
|
|
||||||
func validateConfig(config domain.Config) error {
|
|
||||||
jobsFile := strings.TrimSpace(config.JobsFile)
|
|
||||||
if jobsFile == "" {
|
|
||||||
return errors.New("jobs file is required")
|
|
||||||
}
|
|
||||||
// A path that names only a folder would be written to as if it were a file
|
|
||||||
// and fail later with an opaque OS error, so require a file name here.
|
|
||||||
if !hasFileName(jobsFile) {
|
|
||||||
return errors.New("jobs file must include a file name")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
|
||||||
return errors.New("logs directory is required")
|
|
||||||
}
|
|
||||||
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
|
||||||
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
|
|
||||||
if config.MaxLogFiles < 0 {
|
|
||||||
return errors.New("max log files must be zero (unlimited) or a positive number")
|
|
||||||
}
|
|
||||||
if config.MaxLogAgeDays < 0 {
|
|
||||||
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
|
||||||
}
|
|
||||||
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
|
||||||
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
|
||||||
}
|
|
||||||
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
|
||||||
return errors.New("overlap policy must be 'skip' or 'queue'")
|
|
||||||
}
|
|
||||||
if config.DefaultTimeoutSeconds < 0 {
|
|
||||||
return errors.New("default timeout must not be negative (0 means no timeout)")
|
|
||||||
}
|
|
||||||
// Empty Theme is accepted and normalized to the branded theme on load, so
|
|
||||||
// older configs (and hand-built ones) stay valid without an explicit theme.
|
|
||||||
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
|
|
||||||
return errors.New("theme must be 'system' or 'gosentry'")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
||||||
|
// honoring enabled/paused state. The caller must hold mu.
|
||||||
|
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
||||||
|
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
||||||
|
// time, used when one timestamp should drive a whole batch (e.g. a global
|
||||||
|
// pause). The caller must hold mu.
|
||||||
|
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||||
|
if !job.Enabled {
|
||||||
|
runtime.NextRun = "Paused"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.paused {
|
||||||
|
runtime.NextRun = "Scheduler paused"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.prepareNextRunLocked(job, runtime, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareNextRunLocked computes the concrete next-due time from the cached
|
||||||
|
// schedule. A missing cache entry means the schedule string was unparseable.
|
||||||
|
// The caller must hold mu.
|
||||||
|
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||||
|
sched, ok := s.schedules[job.ID]
|
||||||
|
if !ok {
|
||||||
|
runtime.NextRun = "Invalid schedule"
|
||||||
|
runtime.NextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtime.NextDue = sched.Next(from)
|
||||||
|
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
||||||
|
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
||||||
|
// two apart. The caller must hold mu.
|
||||||
|
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
||||||
|
sched, err := domain.Parse(job.Schedule)
|
||||||
|
if err != nil {
|
||||||
|
delete(s.schedules, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.schedules[job.ID] = sched
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
||||||
|
// given ID, or nil. The caller must hold mu.
|
||||||
|
func (s *Service) findByIDLocked(id int) *domain.Job {
|
||||||
|
index := s.indexByIDLocked(id)
|
||||||
|
if index < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s.jobs[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
||||||
|
// The caller must hold mu.
|
||||||
|
func (s *Service) indexByIDLocked(id int) int {
|
||||||
|
for index := range s.jobs {
|
||||||
|
if s.jobs[index].ID == id {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
||||||
|
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
||||||
|
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
|
||||||
|
runtime, ok := s.runtimes[job.ID]
|
||||||
|
if !ok || runtime == nil {
|
||||||
|
runtime = domain.NewRuntime(*job)
|
||||||
|
s.runtimes[job.ID] = runtime
|
||||||
|
}
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
||||||
|
// caller must hold mu.
|
||||||
|
func (s *Service) nextIDLocked() int {
|
||||||
|
next := 1
|
||||||
|
for index := range s.jobs {
|
||||||
|
if s.jobs[index].ID >= next {
|
||||||
|
next = s.jobs[index].ID + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// prependLog adds a record to the front of a runtime's activity list and caps
|
||||||
|
// its length so it cannot grow without bound.
|
||||||
|
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
||||||
|
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
||||||
|
if len(runtime.Logs) > maxJobLogs {
|
||||||
|
runtime.Logs = runtime.Logs[:maxJobLogs]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiRecord builds an activity record for a user/Service action, using the same
|
||||||
|
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
||||||
|
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
||||||
|
return domain.RunRecord{
|
||||||
|
Time: time.Now().Format(timestampLayout),
|
||||||
|
JobID: jobID,
|
||||||
|
JobName: jobName,
|
||||||
|
Trigger: "UI",
|
||||||
|
State: state,
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetGlobalPause flips the global pause that gates scheduled execution.
|
||||||
|
// Manual "Run now" remains available while paused. Each enabled job's next-run
|
||||||
|
// text reflects the new state immediately so the list view is understandable
|
||||||
|
// before the next tick. A "Paused"/"Resumed" scheduler activity record and a
|
||||||
|
// SchedulerStateChanged event are emitted.
|
||||||
|
func (s *Service) SetGlobalPause(paused bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.paused = paused
|
||||||
|
s.store.Config.Paused = paused
|
||||||
|
now := time.Now()
|
||||||
|
for index := range s.jobs {
|
||||||
|
job := &s.jobs[index]
|
||||||
|
runtime := s.runtimeForLocked(job)
|
||||||
|
if paused {
|
||||||
|
// A "queue" backlog counts occurrences missed *while paused is off*; once
|
||||||
|
// paused, none of those correspond to anything the user would expect
|
||||||
|
// replayed on resume, so drop it rather than letting a stale counter fire
|
||||||
|
// a deferred run for an occurrence from before the pause.
|
||||||
|
runtime.PendingRuns = 0
|
||||||
|
}
|
||||||
|
s.refreshNextRunFromLocked(job, runtime, now)
|
||||||
|
}
|
||||||
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
|
if paused {
|
||||||
|
state, detail = "Paused", "All job execution paused"
|
||||||
|
}
|
||||||
|
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
||||||
|
s.emit(SchedulerStateChanged{Paused: paused})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJobListView persists the Jobs list density preference. Unlike
|
||||||
|
// SetGlobalPause this touches nothing but the config: no job changed, so there
|
||||||
|
// is no SaveJobs, and no event is emitted — the choice is presentational and the
|
||||||
|
// Jobs view refreshes its own list, whereas an event would trigger a pointless
|
||||||
|
// whole-window refresh. Anything that is not "compact" is stored as detailed so
|
||||||
|
// the file never gains an unrecognised value.
|
||||||
|
func (s *Service) SetJobListView(view domain.JobListView) error {
|
||||||
|
if !view.IsCompact() {
|
||||||
|
view = domain.JobListViewDetailed
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.store.Config.JobListView == view {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.store.Config.JobListView = view
|
||||||
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
|
s.mu.Unlock()
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||||
|
// notifications for failed job runs. It reads the config under mu so it is
|
||||||
|
// safe to call from any goroutine.
|
||||||
|
func (s *Service) ShouldNotifyOnFailure() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Config.NotifyOnFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings validates and persists a new application configuration. The
|
||||||
|
// loaded jobs are re-saved because the jobs file may have changed, and log
|
||||||
|
// cleanup runs so a tightened retention policy takes effect immediately.
|
||||||
|
//
|
||||||
|
// Pointing the config at a different jobs file that already exists adopts that
|
||||||
|
// file: its jobs replace the loaded ones, which is the only way the user can
|
||||||
|
// switch between job lists. A path with no file there yet receives the current
|
||||||
|
// jobs instead, which is how the jobs file is renamed or relocated. Adoption
|
||||||
|
// discards all runtime state, so it is refused while a job is running.
|
||||||
|
func (s *Service) UpdateSettings(config domain.Config) error {
|
||||||
|
if err := validateConfig(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The path is stored exactly as it is resolved, so a hand-typed value with
|
||||||
|
// stray spaces cannot make the saved setting and the file in use disagree.
|
||||||
|
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// AppDir is fixed for the process and only UpdateSettings itself — a UI
|
||||||
|
// action — can move JobsPath, so this snapshot stays valid across the reads
|
||||||
|
// below.
|
||||||
|
appDir := s.store.Paths.AppDir
|
||||||
|
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
|
||||||
|
switching := jobsPath != s.store.Paths.JobsPath
|
||||||
|
running := s.anyRunningLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if switching && running {
|
||||||
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
|
}
|
||||||
|
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
||||||
|
// new config points at, before anything is written and while no lock is held:
|
||||||
|
// both are file I/O, and SeedStats opens every log in the directory. A file
|
||||||
|
// that cannot be parsed leaves both the config and the current jobs untouched.
|
||||||
|
var adopted []domain.Job
|
||||||
|
var seeds map[int]runner.SeededStats
|
||||||
|
if switching {
|
||||||
|
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
adopted = jobs
|
||||||
|
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// The guard above was evaluated before the reads, off the lock, so re-check
|
||||||
|
// it: a scheduled run may have started in the meantime, and adoption drops
|
||||||
|
// every runtime.
|
||||||
|
if switching && s.anyRunningLocked() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
|
}
|
||||||
|
s.store.Config = config
|
||||||
|
saveConfig := s.store.PrepareSaveConfig()
|
||||||
|
if adopted != nil {
|
||||||
|
s.adoptJobsLocked(adopted)
|
||||||
|
s.applySeededStatsLocked(seeds)
|
||||||
|
}
|
||||||
|
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
||||||
|
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
||||||
|
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
||||||
|
// that normalization filled in, exactly as loading them at startup would. The
|
||||||
|
// jobs write is skipped when the config write fails, because both writes run
|
||||||
|
// in the order prepared and stop at the first error.
|
||||||
|
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
||||||
|
loaded := len(s.jobs)
|
||||||
|
logsDir := s.store.Paths.LogsDir
|
||||||
|
maxFiles := s.store.Config.MaxLogFiles
|
||||||
|
maxAge := s.store.Config.MaxLogAgeDays
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
saveErr := save()
|
||||||
|
if adopted != nil {
|
||||||
|
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
||||||
|
// History which file those jobs came from, since nothing was asked. Both
|
||||||
|
// are emitted even when the write failed: the adopted jobs are already the
|
||||||
|
// in-memory list, and a job list the user cannot see would be worse than
|
||||||
|
// the error they are about to be shown.
|
||||||
|
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
||||||
|
s.emit(JobChanged{})
|
||||||
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
|
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeJob trims user-entered fields and applies the same defaults the job
|
||||||
|
// dialog used, so callers do not have to.
|
||||||
|
func normalizeJob(job *domain.Job) {
|
||||||
|
job.Name = strings.TrimSpace(job.Name)
|
||||||
|
job.Folder = strings.TrimSpace(job.Folder)
|
||||||
|
job.Schedule = strings.TrimSpace(job.Schedule)
|
||||||
|
job.Command = strings.TrimSpace(job.Command)
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateJob enforces the minimum executable definition: name, schedule, and
|
||||||
|
// command must be present. Folder is optional. The schedule string itself is not
|
||||||
|
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
||||||
|
// next-run, matching the prior behavior.
|
||||||
|
func validateJob(job domain.Job) error {
|
||||||
|
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
||||||
|
return errors.New("name, schedule, and command are required")
|
||||||
|
}
|
||||||
|
policy := strings.TrimSpace(job.OverlapPolicy)
|
||||||
|
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
||||||
|
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
||||||
|
}
|
||||||
|
if job.TimeoutSeconds != nil && *job.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout must be zero (no timeout) or a positive number of seconds, or unset to inherit the global default")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFileName reports whether a path ends in something that can be a file name.
|
||||||
|
// It is a syntax check only — an existing directory whose name looks like a file
|
||||||
|
// name still passes, and fails at write time — but it catches the shapes a user
|
||||||
|
// types when they mean a folder: a trailing separator, "." and "..".
|
||||||
|
func hasFileName(path string) bool {
|
||||||
|
if strings.HasSuffix(path, "/") || strings.HasSuffix(path, string(filepath.Separator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch filepath.Base(path) {
|
||||||
|
case ".", "..", string(filepath.Separator):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig rejects settings that would break persistence or cleanup.
|
||||||
|
func validateConfig(config domain.Config) error {
|
||||||
|
jobsFile := strings.TrimSpace(config.JobsFile)
|
||||||
|
if jobsFile == "" {
|
||||||
|
return errors.New("jobs file is required")
|
||||||
|
}
|
||||||
|
// A path that names only a folder would be written to as if it were a file
|
||||||
|
// and fail later with an opaque OS error, so require a file name here.
|
||||||
|
if !hasFileName(jobsFile) {
|
||||||
|
return errors.New("jobs file must include a file name")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
return errors.New("logs directory is required")
|
||||||
|
}
|
||||||
|
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
||||||
|
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
|
||||||
|
if config.MaxLogFiles < 0 {
|
||||||
|
return errors.New("max log files must be zero (unlimited) or a positive number")
|
||||||
|
}
|
||||||
|
if config.MaxLogAgeDays < 0 {
|
||||||
|
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
||||||
|
}
|
||||||
|
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
||||||
|
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
||||||
|
}
|
||||||
|
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
||||||
|
return errors.New("overlap policy must be 'skip' or 'queue'")
|
||||||
|
}
|
||||||
|
if config.DefaultTimeoutSeconds < 0 {
|
||||||
|
return errors.New("default timeout must not be negative (0 means no timeout)")
|
||||||
|
}
|
||||||
|
// Empty Theme is accepted and normalized to the branded theme on load, so
|
||||||
|
// older configs (and hand-built ones) stay valid without an explicit theme.
|
||||||
|
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
|
||||||
|
return errors.New("theme must be 'system' or 'gosentry'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// Version is the application version shown in the GUI and used by build
|
||||||
// scripts in artifact names. It is a var rather than a const so release builds
|
// scripts in artifact names. It is a var rather than a const so release builds
|
||||||
// can override it with Go ldflags when CI tags a build.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "1.0.2"
|
var Version = "1.0.4"
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
@@ -115,138 +112,6 @@ func (s *Store) SaveJobs(jobs []domain.Job) error {
|
|||||||
return s.PrepareSaveJobs(jobs)()
|
return s.PrepareSaveJobs(jobs)()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|
||||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
|
||||||
// executable, while logs are grouped under a dedicated subdirectory.
|
|
||||||
config := domain.DefaultConfig()
|
|
||||||
|
|
||||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
return config, writeJSON(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(paths.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
|
||||||
// from "the file omits it", which the jobs_dir migration below depends on.
|
|
||||||
// The fallbacks restore a value in either case.
|
|
||||||
config.JobsFile = ""
|
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// A config written before the setting named a file carries jobs_dir instead
|
|
||||||
// of jobs_file. Keep its meaning by appending the fixed name that version
|
|
||||||
// used, then drop the old key so the file is rewritten in the current shape.
|
|
||||||
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
|
||||||
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
|
||||||
}
|
|
||||||
config.JobsDir = ""
|
|
||||||
if strings.TrimSpace(config.JobsFile) == "" {
|
|
||||||
// Empty paths are treated as missing values rather than intentional root
|
|
||||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
|
||||||
config.JobsFile = JobsFileName
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
|
||||||
config.LogsDir = "logs"
|
|
||||||
}
|
|
||||||
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
|
||||||
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
|
||||||
// backfilling it here would make that choice impossible to persist. A config
|
|
||||||
// written before either field existed already carries 0 from json.Unmarshal
|
|
||||||
// leaving the DefaultConfig() value in config untouched, so old files still
|
|
||||||
// pick up 100 / 30 without an explicit backfill.
|
|
||||||
if config.ExecutionMode == "" {
|
|
||||||
config.ExecutionMode = domain.ExecutionModeParallel
|
|
||||||
}
|
|
||||||
if config.OverlapPolicy == "" {
|
|
||||||
config.OverlapPolicy = domain.OverlapPolicySkip
|
|
||||||
}
|
|
||||||
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
|
|
||||||
// value ("no timeout"), not a missing one, so backfilling it here would make
|
|
||||||
// the setting impossible to persist. Negative values are rejected by
|
|
||||||
// app.validateConfig before they can be saved.
|
|
||||||
if config.Theme == "" {
|
|
||||||
config.Theme = domain.ThemeGoSentry
|
|
||||||
}
|
|
||||||
if config.Theme == "default" {
|
|
||||||
config.Theme = domain.ThemeSystem
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
|
||||||
// reports whether the file was there: a missing file is not an error but the
|
|
||||||
// answer to "is this file already a jobs file?", which is what the Settings tab
|
|
||||||
// needs when the user points the application at a different jobs file.
|
|
||||||
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
var file domain.JobsFile
|
|
||||||
if err := json.Unmarshal(data, &file); err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
normalizeJobs(file.Jobs)
|
|
||||||
return file.Jobs, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|
||||||
jobs, found, err := LoadJobsFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return jobs, nil
|
|
||||||
}
|
|
||||||
// Seed sample jobs so a new user can immediately see scheduled and manual
|
|
||||||
// execution without inventing a command. The failure sample stays disabled
|
|
||||||
// so it does not spam notifications; Run now still works for testing.
|
|
||||||
jobs = defaultJobs()
|
|
||||||
normalizeJobs(jobs)
|
|
||||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeJobs(jobs []domain.Job) {
|
|
||||||
next := 1
|
|
||||||
seen := make(map[int]bool, len(jobs))
|
|
||||||
for index := range jobs {
|
|
||||||
job := &jobs[index]
|
|
||||||
if job.ID <= 0 || seen[job.ID] {
|
|
||||||
// IDs are assigned only when absent or already claimed by an earlier job
|
|
||||||
// in this file — a hand-edited jobs.json can carry two entries with the
|
|
||||||
// same ID, which would otherwise share one runtime, one schedule-cache
|
|
||||||
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
|
||||||
// because History and future log associations use them to identify jobs.
|
|
||||||
job.ID = next
|
|
||||||
}
|
|
||||||
seen[job.ID] = true
|
|
||||||
if job.ID >= next {
|
|
||||||
next = job.ID + 1
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Name) == "" {
|
|
||||||
job.Name = "Untitled job"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Schedule) == "" {
|
|
||||||
job.Schedule = "@every 1m"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Command) == "" {
|
|
||||||
// An empty command would fail in a confusing way. A safe echo command
|
|
||||||
// gives the user something observable and harmless instead.
|
|
||||||
job.Command = echoCommand("GoSentry job ran")
|
|
||||||
}
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
// Runtime state (last run, next run, status, output, activity) is no longer
|
|
||||||
// part of Job. It is reconstructed each time the app starts via
|
|
||||||
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveConfiguredPath turns a file or directory path from the config into the
|
// ResolveConfiguredPath turns a file or directory path from the config into the
|
||||||
// absolute path the application will actually use. It is exported so callers
|
// absolute path the application will actually use. It is exported so callers
|
||||||
// outside storage — the settings tab, which opens the configured logs folder —
|
// outside storage — the settings tab, which opens the configured logs folder —
|
||||||
@@ -328,55 +193,3 @@ func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
|||||||
success = true
|
success = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultJobs() []domain.Job {
|
|
||||||
return []domain.Job{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Hello scheduler",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Write timestamp",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "*/1 * * * *",
|
|
||||||
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Name: "Paused sample",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 4,
|
|
||||||
Name: "Failure notification test",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: failCommand(),
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func failCommand() string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "exit /b 1"
|
|
||||||
}
|
|
||||||
return "exit 1"
|
|
||||||
}
|
|
||||||
|
|
||||||
func echoCommand(message string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "echo " + message
|
|
||||||
}
|
|
||||||
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
|
||||||
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
|
||||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
|
config := domain.DefaultConfig()
|
||||||
|
|
||||||
|
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return config, writeJSON(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||||
|
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||||
|
// The fallbacks restore a value in either case.
|
||||||
|
config.JobsFile = ""
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config written before the setting named a file carries jobs_dir instead
|
||||||
|
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||||
|
// used, then drop the old key so the file is rewritten in the current shape.
|
||||||
|
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||||
|
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||||
|
}
|
||||||
|
config.JobsDir = ""
|
||||||
|
if strings.TrimSpace(config.JobsFile) == "" {
|
||||||
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
|
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||||
|
config.JobsFile = JobsFileName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
config.LogsDir = "logs"
|
||||||
|
}
|
||||||
|
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||||
|
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||||
|
// backfilling it here would make that choice impossible to persist. A config
|
||||||
|
// written before either field existed already carries 0 from json.Unmarshal
|
||||||
|
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||||
|
// pick up 100 / 30 without an explicit backfill.
|
||||||
|
if config.ExecutionMode == "" {
|
||||||
|
config.ExecutionMode = domain.ExecutionModeParallel
|
||||||
|
}
|
||||||
|
if config.OverlapPolicy == "" {
|
||||||
|
config.OverlapPolicy = domain.OverlapPolicySkip
|
||||||
|
}
|
||||||
|
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
|
||||||
|
// value ("no timeout"), not a missing one, so backfilling it here would make
|
||||||
|
// the setting impossible to persist. Negative values are rejected by
|
||||||
|
// app.validateConfig before they can be saved.
|
||||||
|
if config.Theme == "" {
|
||||||
|
config.Theme = domain.ThemeGoSentry
|
||||||
|
}
|
||||||
|
if config.Theme == "default" {
|
||||||
|
config.Theme = domain.ThemeSystem
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||||
|
// reports whether the file was there: a missing file is not an error but the
|
||||||
|
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||||
|
// needs when the user points the application at a different jobs file.
|
||||||
|
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
normalizeJobs(file.Jobs)
|
||||||
|
return file.Jobs, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||||
|
jobs, found, err := LoadJobsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||||
|
// execution without inventing a command. The failure sample stays disabled
|
||||||
|
// so it does not spam notifications; Run now still works for testing.
|
||||||
|
jobs = defaultJobs()
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
|
next := 1
|
||||||
|
seen := make(map[int]bool, len(jobs))
|
||||||
|
for index := range jobs {
|
||||||
|
job := &jobs[index]
|
||||||
|
if job.ID <= 0 || seen[job.ID] {
|
||||||
|
// IDs are assigned only when absent or already claimed by an earlier job
|
||||||
|
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||||
|
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||||
|
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||||
|
// because History and future log associations use them to identify jobs.
|
||||||
|
job.ID = next
|
||||||
|
}
|
||||||
|
seen[job.ID] = true
|
||||||
|
if job.ID >= next {
|
||||||
|
next = job.ID + 1
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Name) == "" {
|
||||||
|
job.Name = "Untitled job"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Schedule) == "" {
|
||||||
|
job.Schedule = "@every 1m"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Command) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
|
job.Command = echoCommand("GoSentry job ran")
|
||||||
|
}
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||||
|
// part of Job. It is reconstructed each time the app starts via
|
||||||
|
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultJobs() []domain.Job {
|
||||||
|
return []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Hello scheduler",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Write timestamp",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "*/1 * * * *",
|
||||||
|
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Paused sample",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Failure notification test",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: failCommand(),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failCommand() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "exit /b 1"
|
||||||
|
}
|
||||||
|
return "exit 1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func echoCommand(message string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "echo " + message
|
||||||
|
}
|
||||||
|
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
||||||
|
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
||||||
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -24,87 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// textWidth measures how wide s renders at the theme's current body text size.
|
|
||||||
func textWidth(s string) float32 {
|
|
||||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
|
||||||
}
|
|
||||||
|
|
||||||
// cellPadding is the horizontal space a table cell reserves around its text.
|
|
||||||
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
|
||||||
// doubled (one side each), so it follows text size and DPI.
|
|
||||||
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
|
||||||
|
|
||||||
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
|
||||||
// column: the minimum keeps a column readable when its values are short or
|
|
||||||
// absent, the maximum stops one very long value from dominating the table
|
|
||||||
// (the table still scrolls horizontally past it). Expressed as measured text
|
|
||||||
// rather than raw pixels so both follow the theme instead of drifting from it.
|
|
||||||
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
|
||||||
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
|
||||||
|
|
||||||
// textColumnWidth measures the widest of samples so a table column can be
|
|
||||||
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
|
||||||
// auto-size columns, so without this a fixed width clips values like
|
|
||||||
// "20260601-100000_SomeJobName.log" in the Log column.
|
|
||||||
func textColumnWidth(samples []string, min, max float32) float32 {
|
|
||||||
width := min
|
|
||||||
for _, text := range samples {
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if w := textWidth(text) + cellPadding(); w > width {
|
|
||||||
width = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if width > max {
|
|
||||||
width = max
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
// historyTriggerSamples is the closed set of Trigger values History ever
|
|
||||||
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
|
||||||
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
|
||||||
// trigger here too if one is introduced there, or the column may clip it.
|
|
||||||
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
|
||||||
|
|
||||||
// historyStateSamples is the closed set of State values History ever shows:
|
|
||||||
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
|
||||||
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
|
||||||
// Add a new state here too if one is introduced in either place.
|
|
||||||
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
|
||||||
|
|
||||||
// historyTimeSample is the rendered form of the timestamp layout every event
|
|
||||||
// uses (see newEvent), so the Time column needs no content scan: its width is
|
|
||||||
// fixed by the format string.
|
|
||||||
const historyTimeSample = "2026-01-02 15:04:05"
|
|
||||||
|
|
||||||
// historyColumnWidths computes every column's width from the current sorted
|
|
||||||
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
|
||||||
// Detail and Log are free text, so their width tracks the values actually
|
|
||||||
// present, bounded the same way the Log column always was.
|
|
||||||
func historyColumnWidths(rows []event) [6]float32 {
|
|
||||||
var content [3][]string
|
|
||||||
for i := range content {
|
|
||||||
content[i] = make([]string, 0, len(rows))
|
|
||||||
}
|
|
||||||
for _, current := range rows {
|
|
||||||
for i, value := range historyContentValues(current) {
|
|
||||||
content[i] = append(content[i], value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
|
||||||
widths := [6]float32{
|
|
||||||
0: textWidth(historyTimeSample) + cellPadding(),
|
|
||||||
1: textColumnWidth(historyTriggerSamples, min, max),
|
|
||||||
3: textColumnWidth(historyStateSamples, min, max),
|
|
||||||
}
|
|
||||||
for i, col := range historyContentCols {
|
|
||||||
widths[col] = textColumnWidth(content[i], min, max)
|
|
||||||
}
|
|
||||||
return widths
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
|
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
|
||||||
// job's own activity list. History is never persisted and every record carries
|
// job's own activity list. History is never persisted and every record carries
|
||||||
// the run's full captured output, so an app left running in the tray — the mode
|
// the run's full captured output, so an app left running in the tray — the mode
|
||||||
@@ -183,16 +102,6 @@ func (h *historyLog) rescan() {
|
|||||||
h.widths = historyColumnWidths(h.records)
|
h.widths = historyColumnWidths(h.records)
|
||||||
}
|
}
|
||||||
|
|
||||||
// historyContentCols are the columns whose width follows the values actually
|
|
||||||
// present, in the order historyContentValues returns them. Both the
|
|
||||||
// incremental fold in add and the full scan in historyColumnWidths go through
|
|
||||||
// this pair, so they cannot disagree about which columns follow content.
|
|
||||||
var historyContentCols = [3]int{2, 4, 5}
|
|
||||||
|
|
||||||
func historyContentValues(record event) [3]string {
|
|
||||||
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// historyHeader is a bold tappable label used in the History table header row.
|
// historyHeader is a bold tappable label used in the History table header row.
|
||||||
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
||||||
// toggle is wired through the Tappable interface instead.
|
// toggle is wired through the Tappable interface instead.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// textWidth measures how wide s renders at the theme's current body text size.
|
||||||
|
func textWidth(s string) float32 {
|
||||||
|
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||||
|
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||||
|
// doubled (one side each), so it follows text size and DPI.
|
||||||
|
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||||
|
|
||||||
|
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||||
|
// column: the minimum keeps a column readable when its values are short or
|
||||||
|
// absent, the maximum stops one very long value from dominating the table
|
||||||
|
// (the table still scrolls horizontally past it). Expressed as measured text
|
||||||
|
// rather than raw pixels so both follow the theme instead of drifting from it.
|
||||||
|
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
||||||
|
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
||||||
|
|
||||||
|
// textColumnWidth measures the widest of samples so a table column can be
|
||||||
|
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
||||||
|
// auto-size columns, so without this a fixed width clips values like
|
||||||
|
// "20260601-100000_SomeJobName.log" in the Log column.
|
||||||
|
func textColumnWidth(samples []string, min, max float32) float32 {
|
||||||
|
width := min
|
||||||
|
for _, text := range samples {
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if w := textWidth(text) + cellPadding(); w > width {
|
||||||
|
width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width > max {
|
||||||
|
width = max
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyTriggerSamples is the closed set of Trigger values History ever
|
||||||
|
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
||||||
|
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
||||||
|
// trigger here too if one is introduced there, or the column may clip it.
|
||||||
|
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
||||||
|
|
||||||
|
// historyStateSamples is the closed set of State values History ever shows:
|
||||||
|
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
||||||
|
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
||||||
|
// Add a new state here too if one is introduced in either place.
|
||||||
|
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
||||||
|
|
||||||
|
// historyTimeSample is the rendered form of the timestamp layout every event
|
||||||
|
// uses (see newEvent), so the Time column needs no content scan: its width is
|
||||||
|
// fixed by the format string.
|
||||||
|
const historyTimeSample = "2026-01-02 15:04:05"
|
||||||
|
|
||||||
|
// historyColumnWidths computes every column's width from the current sorted
|
||||||
|
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
||||||
|
// Detail and Log are free text, so their width tracks the values actually
|
||||||
|
// present, bounded the same way the Log column always was.
|
||||||
|
func historyColumnWidths(rows []event) [6]float32 {
|
||||||
|
var content [3][]string
|
||||||
|
for i := range content {
|
||||||
|
content[i] = make([]string, 0, len(rows))
|
||||||
|
}
|
||||||
|
for _, current := range rows {
|
||||||
|
for i, value := range historyContentValues(current) {
|
||||||
|
content[i] = append(content[i], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||||
|
widths := [6]float32{
|
||||||
|
0: textWidth(historyTimeSample) + cellPadding(),
|
||||||
|
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||||
|
3: textColumnWidth(historyStateSamples, min, max),
|
||||||
|
}
|
||||||
|
for i, col := range historyContentCols {
|
||||||
|
widths[col] = textColumnWidth(content[i], min, max)
|
||||||
|
}
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyContentCols are the columns whose width follows the values actually
|
||||||
|
// present, in the order historyContentValues returns them. Both the
|
||||||
|
// incremental fold in add and the full scan in historyColumnWidths go through
|
||||||
|
// this pair, so they cannot disagree about which columns follow content.
|
||||||
|
var historyContentCols = [3]int{2, 4, 5}
|
||||||
|
|
||||||
|
func historyContentValues(record event) [3]string {
|
||||||
|
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
|
||||||
|
}
|
||||||
+1
-273
@@ -1,14 +1,10 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/theme"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,275 +23,7 @@ var settingsCaptions = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) 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
|
return newSettingsLayout(buildSettingsForm(w, svc, tray))
|
||||||
// construction); it is a local copy the closures below compare the form
|
|
||||||
// against and reassign after a successful save, rather than holding onto
|
|
||||||
// the live *storage.Store the Service owns (see app.Service.Config).
|
|
||||||
// paths never changes after construction of this view — AppDir and
|
|
||||||
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
|
||||||
saved := svc.Config()
|
|
||||||
paths := svc.Paths()
|
|
||||||
// updateSaveState compares the form to the saved config and enables Save only
|
|
||||||
// when something differs. It is defined below (once Save and every field
|
|
||||||
// exist) but declared here so the field change handlers can reference it.
|
|
||||||
var updateSaveState func()
|
|
||||||
// loadFields populates every form control from the given config. It backs
|
|
||||||
// both the initial load and the Cancel/Defaults buttons below.
|
|
||||||
var loadFields func(domain.Config)
|
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
|
||||||
startOnLogin.SetChecked(saved.StartOnLogin)
|
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
|
||||||
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
|
||||||
autostartStatus := widget.NewLabel("")
|
|
||||||
trayRestartHint := widget.NewLabel("")
|
|
||||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
|
||||||
// autostartCheckGen guards against an in-flight check's result landing after
|
|
||||||
// a newer one started (e.g. the user toggles a checkbox again before the
|
|
||||||
// first check's PowerShell call returns). Both the increment and the compare
|
|
||||||
// happen on the main/Fyne thread, so this needs no lock of its own.
|
|
||||||
var autostartCheckGen int
|
|
||||||
refreshAutostartStatus := func() {
|
|
||||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
|
||||||
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
|
||||||
// of cold start. Running it off the main thread keeps that from freezing
|
|
||||||
// the window on construction and on every checkbox toggle.
|
|
||||||
autostartStatus.SetText("Checking...")
|
|
||||||
autostartCheckGen++
|
|
||||||
gen := autostartCheckGen
|
|
||||||
go func() {
|
|
||||||
ok, message := svc.AutostartStatus()
|
|
||||||
fyne.Do(func() {
|
|
||||||
if gen != autostartCheckGen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
autostartStatus.SetText("OK: " + message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
autostartStatus.SetText("Problem: " + message)
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
refreshTrayRestartHint := func(pending bool) {
|
|
||||||
if pending {
|
|
||||||
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trayRestartHint.SetText("")
|
|
||||||
}
|
|
||||||
startOnLogin.OnChanged = func(bool) {
|
|
||||||
refreshAutostartStatus()
|
|
||||||
updateSaveState()
|
|
||||||
}
|
|
||||||
minimizeToTray.OnChanged = func(bool) {
|
|
||||||
refreshAutostartStatus()
|
|
||||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
|
||||||
updateSaveState()
|
|
||||||
}
|
|
||||||
refreshAutostartStatus()
|
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
|
||||||
notifications.SetChecked(saved.NotifyOnFailure)
|
|
||||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
|
||||||
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
|
||||||
themeSelect.SetSelected(themeLabel(saved.Theme))
|
|
||||||
// Preview the theme the moment it is picked so the choice is visible before
|
|
||||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
|
||||||
// closing without saving falls back to the stored theme on next launch.
|
|
||||||
themeSelect.OnChanged = func(string) {
|
|
||||||
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
|
||||||
updateSaveState()
|
|
||||||
}
|
|
||||||
executionModeSelect := widget.NewSelect(
|
|
||||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
executionModeSelect.SetSelected(string(saved.ExecutionMode))
|
|
||||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
|
||||||
overlapPolicySelect := widget.NewSelect(
|
|
||||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
|
||||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
|
||||||
defaultTimeout := widget.NewEntry()
|
|
||||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
|
||||||
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
|
||||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
|
||||||
jobsFile := widget.NewEntry()
|
|
||||||
jobsFile.SetText(saved.JobsFile)
|
|
||||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
|
||||||
// The picker only offers existing files; a jobs file that does not exist yet
|
|
||||||
// is entered by typing its path, which Save then creates.
|
|
||||||
jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() {
|
|
||||||
chooseJSONFile(w, jobsFile)
|
|
||||||
})
|
|
||||||
logsDir := widget.NewEntry()
|
|
||||||
logsDir.SetText(saved.LogsDir)
|
|
||||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
|
||||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
|
||||||
chooseFolder(w, logsDir)
|
|
||||||
})
|
|
||||||
// Log files are read outside the app, so the folder gets a direct shortcut
|
|
||||||
// beside its path instead of making the user copy the path into a file
|
|
||||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
|
||||||
// checked before Save.
|
|
||||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
|
||||||
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
|
||||||
})
|
|
||||||
maxLogFiles := widget.NewEntry()
|
|
||||||
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
|
||||||
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
|
||||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
|
||||||
maxLogAgeDays := widget.NewEntry()
|
|
||||||
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
|
|
||||||
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
|
||||||
// Autostart status sits on its own row beneath the checkbox (rather than
|
|
||||||
// beside it) so the Application section fits within a half-width column.
|
|
||||||
// Truncating keeps a long status message from forcing the column wider.
|
|
||||||
autostartStatus.Truncation = fyne.TextTruncateClip
|
|
||||||
settingsStatus := widget.NewLabel("")
|
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
|
||||||
// Only the parse itself happens here: a numeric field has to become an int
|
|
||||||
// before it can go into a domain.Config at all. Everything else — required
|
|
||||||
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
|
||||||
// job (see app.validateConfig), so its error is what the user sees rather
|
|
||||||
// than a second copy of the same rules with different wording.
|
|
||||||
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
|
||||||
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
|
||||||
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
|
||||||
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
|
||||||
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Build the new config from the form and hand it to the Service, which
|
|
||||||
// validates it, persists config and jobs to the (possibly new) directory,
|
|
||||||
// and runs log cleanup so tightened retention limits take effect at once.
|
|
||||||
config := saved
|
|
||||||
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
|
||||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
|
||||||
config.MaxLogFiles = files
|
|
||||||
config.MaxLogAgeDays = days
|
|
||||||
config.StartOnLogin = startOnLogin.Checked
|
|
||||||
config.KeepRunningInTray = minimizeToTray.Checked
|
|
||||||
config.NotifyOnFailure = notifications.Checked
|
|
||||||
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
|
||||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
|
||||||
config.DefaultTimeoutSeconds = timeout
|
|
||||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
|
||||||
previousKeepInTray := saved.KeepRunningInTray
|
|
||||||
if err := svc.UpdateSettings(config); err != nil {
|
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
|
||||||
// different directory), so pick up the fresh copy rather than assuming
|
|
||||||
// config is exactly what landed.
|
|
||||||
saved = svc.Config()
|
|
||||||
paths = svc.Paths()
|
|
||||||
if err := svc.ApplyAutostart(); err != nil {
|
|
||||||
refreshAutostartStatus()
|
|
||||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshAutostartStatus()
|
|
||||||
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
|
||||||
if previousKeepInTray != config.KeepRunningInTray {
|
|
||||||
trayRestartHint.SetText(trayRestartHintText)
|
|
||||||
} else {
|
|
||||||
refreshTrayRestartHint(false)
|
|
||||||
}
|
|
||||||
settingsStatus.SetText("Saved")
|
|
||||||
// The form now matches the persisted config, so disable Save again.
|
|
||||||
updateSaveState()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Save stays disabled until a field differs from the saved config, so the
|
|
||||||
// button only invites a click when there is something to persist. The numeric
|
|
||||||
// fields compare against their canonical string form; any unparsable text
|
|
||||||
// counts as a change so the user can click Save and see the validation error.
|
|
||||||
updateSaveState = func() {
|
|
||||||
c := saved
|
|
||||||
changed := startOnLogin.Checked != c.StartOnLogin ||
|
|
||||||
minimizeToTray.Checked != c.KeepRunningInTray ||
|
|
||||||
notifications.Checked != c.NotifyOnFailure ||
|
|
||||||
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
|
||||||
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
|
||||||
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
|
||||||
strings.TrimSpace(jobsFile.Text) != c.JobsFile ||
|
|
||||||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
|
||||||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
|
||||||
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) ||
|
|
||||||
themeSelect.Selected != themeLabel(c.Theme)
|
|
||||||
if changed {
|
|
||||||
saveSettings.Enable()
|
|
||||||
} else {
|
|
||||||
saveSettings.Disable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateSaveState()
|
|
||||||
|
|
||||||
// loadFields populates every form control from a config without saving it,
|
|
||||||
// backing both the Cancel button (reload the saved config, discarding edits)
|
|
||||||
// and the Defaults button (load the built-in defaults for review before
|
|
||||||
// Save is clicked).
|
|
||||||
loadFields = func(c domain.Config) {
|
|
||||||
startOnLogin.SetChecked(c.StartOnLogin)
|
|
||||||
minimizeToTray.SetChecked(c.KeepRunningInTray)
|
|
||||||
notifications.SetChecked(c.NotifyOnFailure)
|
|
||||||
themeSelect.SetSelected(themeLabel(c.Theme))
|
|
||||||
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
|
||||||
executionModeSelect.SetSelected(string(c.ExecutionMode))
|
|
||||||
overlapPolicySelect.SetSelected(string(c.OverlapPolicy))
|
|
||||||
defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds))
|
|
||||||
jobsFile.SetText(c.JobsFile)
|
|
||||||
logsDir.SetText(c.LogsDir)
|
|
||||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
|
||||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
|
||||||
} else {
|
|
||||||
refreshAutostartStatus()
|
|
||||||
}
|
|
||||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
|
||||||
settingsStatus.SetText("")
|
|
||||||
updateSaveState()
|
|
||||||
}
|
|
||||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
|
||||||
loadFields(saved)
|
|
||||||
})
|
|
||||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
|
||||||
loadFields(domain.DefaultConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
return newSettingsLayout(settingsFormFields{
|
|
||||||
startOnLogin: startOnLogin,
|
|
||||||
autostartStatus: autostartStatus,
|
|
||||||
minimizeToTray: minimizeToTray,
|
|
||||||
trayRestartHint: trayRestartHint,
|
|
||||||
notifications: notifications,
|
|
||||||
themeSelect: themeSelect,
|
|
||||||
executionModeSelect: executionModeSelect,
|
|
||||||
overlapPolicySelect: overlapPolicySelect,
|
|
||||||
defaultTimeout: defaultTimeout,
|
|
||||||
configPath: paths.ConfigPath,
|
|
||||||
jobsFile: jobsFile,
|
|
||||||
jobsFileBrowse: jobsFileBrowse,
|
|
||||||
logsDir: logsDir,
|
|
||||||
logsDirOpen: logsDirOpen,
|
|
||||||
logsDirBrowse: logsDirBrowse,
|
|
||||||
maxLogFiles: maxLogFiles,
|
|
||||||
maxLogAgeDays: maxLogAgeDays,
|
|
||||||
saveSettings: saveSettings,
|
|
||||||
cancelSettings: cancelSettings,
|
|
||||||
restoreDefaults: restoreDefaults,
|
|
||||||
settingsStatus: settingsStatus,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
|
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildSettingsForm constructs every Settings tab widget and wires save, load,
|
||||||
|
// and cancel handlers. settingsView delegates here so the constructor file stays
|
||||||
|
// focused on the thin entry point and theme label helpers.
|
||||||
|
func buildSettingsForm(w fyne.Window, svc *app.Service, tray *trayState) settingsFormFields {
|
||||||
|
// saved mirrors the config as last persisted (or freshly loaded at
|
||||||
|
// construction); it is a local copy the closures below compare the form
|
||||||
|
// against and reassign after a successful save, rather than holding onto
|
||||||
|
// the live *storage.Store the Service owns (see app.Service.Config).
|
||||||
|
// paths never changes after construction of this view — AppDir and
|
||||||
|
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
||||||
|
saved := svc.Config()
|
||||||
|
paths := svc.Paths()
|
||||||
|
// updateSaveState compares the form to the saved config and enables Save only
|
||||||
|
// when something differs. It is defined below (once Save and every field
|
||||||
|
// exist) but declared here so the field change handlers can reference it.
|
||||||
|
var updateSaveState func()
|
||||||
|
// loadFields populates every form control from the given config. It backs
|
||||||
|
// both the initial load and the Cancel/Defaults buttons below.
|
||||||
|
var loadFields func(domain.Config)
|
||||||
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
|
startOnLogin.SetChecked(saved.StartOnLogin)
|
||||||
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
|
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||||
|
autostartStatus := widget.NewLabel("")
|
||||||
|
trayRestartHint := widget.NewLabel("")
|
||||||
|
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||||
|
// autostartCheckGen guards against an in-flight check's result landing after
|
||||||
|
// a newer one started (e.g. the user toggles a checkbox again before the
|
||||||
|
// first check's PowerShell call returns). Both the increment and the compare
|
||||||
|
// happen on the main/Fyne thread, so this needs no lock of its own.
|
||||||
|
var autostartCheckGen int
|
||||||
|
refreshAutostartStatus := func() {
|
||||||
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
||||||
|
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
||||||
|
// of cold start. Running it off the main thread keeps that from freezing
|
||||||
|
// the window on construction and on every checkbox toggle.
|
||||||
|
autostartStatus.SetText("Checking...")
|
||||||
|
autostartCheckGen++
|
||||||
|
gen := autostartCheckGen
|
||||||
|
go func() {
|
||||||
|
ok, message := svc.AutostartStatus()
|
||||||
|
fyne.Do(func() {
|
||||||
|
if gen != autostartCheckGen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
autostartStatus.SetText("OK: " + message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autostartStatus.SetText("Problem: " + message)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
refreshTrayRestartHint := func(pending bool) {
|
||||||
|
if pending {
|
||||||
|
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trayRestartHint.SetText("")
|
||||||
|
}
|
||||||
|
startOnLogin.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
|
minimizeToTray.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
|
notifications.SetChecked(saved.NotifyOnFailure)
|
||||||
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||||
|
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||||
|
themeSelect.SetSelected(themeLabel(saved.Theme))
|
||||||
|
// Preview the theme the moment it is picked so the choice is visible before
|
||||||
|
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||||
|
// closing without saving falls back to the stored theme on next launch.
|
||||||
|
themeSelect.OnChanged = func(string) {
|
||||||
|
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
|
executionModeSelect := widget.NewSelect(
|
||||||
|
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
executionModeSelect.SetSelected(string(saved.ExecutionMode))
|
||||||
|
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||||
|
overlapPolicySelect := widget.NewSelect(
|
||||||
|
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||||
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||||
|
defaultTimeout := widget.NewEntry()
|
||||||
|
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||||
|
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||||
|
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||||
|
jobsFile := widget.NewEntry()
|
||||||
|
jobsFile.SetText(saved.JobsFile)
|
||||||
|
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||||
|
// The picker only offers existing files; a jobs file that does not exist yet
|
||||||
|
// is entered by typing its path, which Save then creates.
|
||||||
|
jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() {
|
||||||
|
chooseJSONFile(w, jobsFile)
|
||||||
|
})
|
||||||
|
logsDir := widget.NewEntry()
|
||||||
|
logsDir.SetText(saved.LogsDir)
|
||||||
|
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||||
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
|
chooseFolder(w, logsDir)
|
||||||
|
})
|
||||||
|
// Log files are read outside the app, so the folder gets a direct shortcut
|
||||||
|
// beside its path instead of making the user copy the path into a file
|
||||||
|
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||||
|
// checked before Save.
|
||||||
|
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||||
|
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
||||||
|
})
|
||||||
|
maxLogFiles := widget.NewEntry()
|
||||||
|
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||||
|
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||||
|
maxLogAgeDays := widget.NewEntry()
|
||||||
|
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
|
||||||
|
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
||||||
|
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||||
|
// beside it) so the Application section fits within a half-width column.
|
||||||
|
// Truncating keeps a long status message from forcing the column wider.
|
||||||
|
autostartStatus.Truncation = fyne.TextTruncateClip
|
||||||
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
|
// Only the parse itself happens here: a numeric field has to become an int
|
||||||
|
// before it can go into a domain.Config at all. Everything else — required
|
||||||
|
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||||
|
// job (see app.validateConfig), so its error is what the user sees rather
|
||||||
|
// than a second copy of the same rules with different wording.
|
||||||
|
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||||
|
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||||
|
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||||
|
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||||
|
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build the new config from the form and hand it to the Service, which
|
||||||
|
// validates it, persists config and jobs to the (possibly new) directory,
|
||||||
|
// and runs log cleanup so tightened retention limits take effect at once.
|
||||||
|
config := saved
|
||||||
|
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
||||||
|
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||||
|
config.MaxLogFiles = files
|
||||||
|
config.MaxLogAgeDays = days
|
||||||
|
config.StartOnLogin = startOnLogin.Checked
|
||||||
|
config.KeepRunningInTray = minimizeToTray.Checked
|
||||||
|
config.NotifyOnFailure = notifications.Checked
|
||||||
|
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
||||||
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||||
|
config.DefaultTimeoutSeconds = timeout
|
||||||
|
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||||
|
previousKeepInTray := saved.KeepRunningInTray
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
||||||
|
// different directory), so pick up the fresh copy rather than assuming
|
||||||
|
// config is exactly what landed.
|
||||||
|
saved = svc.Config()
|
||||||
|
paths = svc.Paths()
|
||||||
|
if err := svc.ApplyAutostart(); err != nil {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
|
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||||
|
if previousKeepInTray != config.KeepRunningInTray {
|
||||||
|
trayRestartHint.SetText(trayRestartHintText)
|
||||||
|
} else {
|
||||||
|
refreshTrayRestartHint(false)
|
||||||
|
}
|
||||||
|
settingsStatus.SetText("Saved")
|
||||||
|
// The form now matches the persisted config, so disable Save again.
|
||||||
|
updateSaveState()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save stays disabled until a field differs from the saved config, so the
|
||||||
|
// button only invites a click when there is something to persist. The numeric
|
||||||
|
// fields compare against their canonical string form; any unparsable text
|
||||||
|
// counts as a change so the user can click Save and see the validation error.
|
||||||
|
updateSaveState = func() {
|
||||||
|
c := saved
|
||||||
|
changed := startOnLogin.Checked != c.StartOnLogin ||
|
||||||
|
minimizeToTray.Checked != c.KeepRunningInTray ||
|
||||||
|
notifications.Checked != c.NotifyOnFailure ||
|
||||||
|
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
||||||
|
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
||||||
|
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
||||||
|
strings.TrimSpace(jobsFile.Text) != c.JobsFile ||
|
||||||
|
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
||||||
|
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
||||||
|
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) ||
|
||||||
|
themeSelect.Selected != themeLabel(c.Theme)
|
||||||
|
if changed {
|
||||||
|
saveSettings.Enable()
|
||||||
|
} else {
|
||||||
|
saveSettings.Disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSaveState()
|
||||||
|
|
||||||
|
// loadFields populates every form control from a config without saving it,
|
||||||
|
// backing both the Cancel button (reload the saved config, discarding edits)
|
||||||
|
// and the Defaults button (load the built-in defaults for review before
|
||||||
|
// Save is clicked).
|
||||||
|
loadFields = func(c domain.Config) {
|
||||||
|
startOnLogin.SetChecked(c.StartOnLogin)
|
||||||
|
minimizeToTray.SetChecked(c.KeepRunningInTray)
|
||||||
|
notifications.SetChecked(c.NotifyOnFailure)
|
||||||
|
themeSelect.SetSelected(themeLabel(c.Theme))
|
||||||
|
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
||||||
|
executionModeSelect.SetSelected(string(c.ExecutionMode))
|
||||||
|
overlapPolicySelect.SetSelected(string(c.OverlapPolicy))
|
||||||
|
defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds))
|
||||||
|
jobsFile.SetText(c.JobsFile)
|
||||||
|
logsDir.SetText(c.LogsDir)
|
||||||
|
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||||
|
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||||
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
|
} else {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
}
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
|
settingsStatus.SetText("")
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
|
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||||
|
loadFields(saved)
|
||||||
|
})
|
||||||
|
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||||
|
loadFields(domain.DefaultConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
return settingsFormFields{
|
||||||
|
startOnLogin: startOnLogin,
|
||||||
|
autostartStatus: autostartStatus,
|
||||||
|
minimizeToTray: minimizeToTray,
|
||||||
|
trayRestartHint: trayRestartHint,
|
||||||
|
notifications: notifications,
|
||||||
|
themeSelect: themeSelect,
|
||||||
|
executionModeSelect: executionModeSelect,
|
||||||
|
overlapPolicySelect: overlapPolicySelect,
|
||||||
|
defaultTimeout: defaultTimeout,
|
||||||
|
configPath: paths.ConfigPath,
|
||||||
|
jobsFile: jobsFile,
|
||||||
|
jobsFileBrowse: jobsFileBrowse,
|
||||||
|
logsDir: logsDir,
|
||||||
|
logsDirOpen: logsDirOpen,
|
||||||
|
logsDirBrowse: logsDirBrowse,
|
||||||
|
maxLogFiles: maxLogFiles,
|
||||||
|
maxLogAgeDays: maxLogAgeDays,
|
||||||
|
saveSettings: saveSettings,
|
||||||
|
cancelSettings: cancelSettings,
|
||||||
|
restoreDefaults: restoreDefaults,
|
||||||
|
settingsStatus: settingsStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user