Compare commits

162 Commits

Author SHA1 Message Date
mixeme e482e3261c docs: cut GitHub releases so the push mirror cannot delete them
GitHub is a pruning push mirror of Gitea, so a tag created by
"gh release create" belongs to no upstream ref and disappears on the next
synchronisation, orphaning the release and its archives. Document the order
that survives it: push the tag to Gitea, wait for the mirror, verify the tag
on GitHub, publish with --verify-tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:17:14 +03:00
mixeme a926e90196 docs: give "Cutting a release" its pre-tag checklist
The section began at the tag, so everything that has to be true before the
tag exists was either implied or nowhere: the version bump was named only
as the thing the tag must match, and the changelog entry, the test run and
the push were not mentioned at all. The push matters because the tag has
to land on a commit the forge has.

The screenshots are the reason for writing this down. README.md is
packaged into all three release archives and is what the forge renders on
the project page, and nothing fails when the shots are stale — the release
just ships pictures of an older GUI. This project changes visible UI most
releases, so the default assumption should be that they need retaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:29:42 +03:00
mixeme 16cf2b9e30 docs: record the documentation pass in the 1.0.0 changelog
Six commits landed after the 1.0.0 section was written, and none of them
appear in it. The release was never tagged or pushed, so they belong in
that section rather than in a 1.0.1 for changes no one could have run:
version.go stays at 1.0.0. This is the same call bde9a2e made for 0.16.0.

They are worth recording rather than dropping as "docs only". README.md
ships inside every release archive, and its gosentry.json sample — the one
file the user is invited to hand-edit — was wrong until this pass. The
rest is the contributor documentation catching up with the code it
describes: TESTS.md indexing 130 of 170 tests, ARCHITECTURE drawing an
edge the UI does not have, and the file-length guideline stated as settled
while six files are over it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:21:08 +03:00
mixeme 0cf44e1dfa docs: spell out what a missing RELEASE_TOKEN actually does
The note said Codeberg needs the secret but not what happens without it,
so the failure mode had to be inferred from a red job. Record it: build
and packaging still succeed, the upload step fails on authentication and
takes the job with it, and the release is published with no assets.

Not "the upload step is skipped", which is what the comment in
.forgejo/workflows/release.yml claims. That step is guarded only by
`if: github.event_name == 'release'` — there is no token check and no
continue-on-error, so an absent or unscoped token makes it fail rather
than skip. Skipping is the workflow_dispatch path. The workflow comment
is still wrong and is left for a separate change.

Also name why GitHub needs no setup: softprops/action-gh-release falls
back to the built-in GITHUB_TOKEN and the workflow grants contents:write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:17:34 +03:00
mixeme 44bc7ee81e docs: say which OS each Packaging command belongs to
The two package-* invocations sat next to each other with nothing but
the code-fence language to tell them apart, and the output paths were
pooled in one block that mixed Linux tarballs with the Windows zip.
Label each command Windows: / Linux: like Run From Source does, and give
each its own artifact list.

Also note that package-linux.sh emits the arm64 tarball only when
aarch64-linux-gnu-gcc is installed; the flat list implied a plain Linux
host always produces both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:13:19 +03:00
mixeme 0f171edd75 docs: reorder DEVELOPMENT.md and give it a table of contents
The document opened with dependency installation and buried "Run From
Source" between the build and release sections, so a newcomer met the
MSYS2 setup before learning what the stack was. Reorder it as stack and
tools, external libraries, run from source, build, release, CI, and add
a clickable two-level Contents block.

External libraries now list version, repository, and license type in one
table, and the package-* scripts are documented for the first time. The
Project Layout section is gone: it duplicated the package map in
ARCHITECTURE.md and had drifted out of date, missing
src/platform/filemanager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:08:03 +03:00
mixeme 9a45a7be6f fix(ui): give the Settings Theme row back its top gap
The Application section stacks its rows with rowOverlap(), a negative
spacing that trades away one label's duplicated text inset. The Theme
row's value is a Select, which paints its box out to the row's edge and
has no inset to give, so the overlap closed the gap instead: 0.46 px
between the Notifications checkbox and the dropdown, against ~8 px
between the checkbox rows.

cancelRowOverlap adds that one padding back on the Theme row's top edge
only, restoring the gap to 7.5 px without touching the other rows or the
column width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:04:18 +03:00
mixeme 9dd461e35e docs: track the oversized files as a roadmap item
The ~250-line guideline is currently broken by six source files, not the
two the last commit named from the ui section it was editing:
operations.go at 490 is the worst, and both files that were already split
once are back over.

Fixing them belongs in the next whole-project review rather than in
one-off commits. REVIEW.md item 2 already asks for exactly this sweep, and
doing all six together is what keeps the seams consistent — six separate
passes would settle the same question six ways. A split also reads as pure
movement while it is the easiest change in which to silently drop a
function, which is an argument for one careful pass rather than several
hurried ones.

The item records the seams that are visible today so the pass does not
start cold: operations.go splits along the three consecutive blocks it
already has, history_view.go's column measurement is pure and separable,
and jobs_view.go is the hard one because almost all of it is a single
constructor that has to be broken up rather than moved. The three files
barely over the line are flagged as re-measure-first, not split-on-sight.

ARCHITECTURE now points at that item instead of describing the overage in
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:44:03 +03:00
mixeme 84e81371c1 docs: correct the claims that no longer match the code
Four documents asserted things the code contradicts.

ARCHITECTURE's component diagram had the UI calling the autostart Manager
directly. It does not, and must not: src/ui holds no reference to the
package at all — Settings reads svc.AutostartStatus(), like everything else
it reads. The edge is folded into the existing ui→Service one, so the
diagram no longer draws the exception to the project's own rule.

platform/desktop was described in both ARCHITECTURE and DEVELOPMENT as a
"display-scale helper". It installs the .desktop entry and icon under XDG
data home; there is no scale helper in it.

The ~250-line file guideline was written as though the jobs_view and
settings_view splits had settled it. Both files are over it again and
history_view.go has never been split, so the guideline is now stated as
the target it is, with the current state named rather than implied.

STANDARDS pointed at a "CI coverage gate" item that ROADMAP does not have,
while omitting the two it does.

README's gosentry.json sample was three keys short of what the app writes
on first run — default_timeout_seconds, theme and job_list_view — which
made the one file the user is invited to hand-edit the least accurate
thing in the document. The sample is now the real default (verified by
marshalling DefaultConfig), with the keys explained, including why a zero
timeout is written out and an unset one is not. The per-job overrides for
overlap policy and timeout were undocumented despite being in the job
dialog, and the feature list had not caught up with the timeout, the theme,
or the compact job list.

Version numbers in example output paths are now <version>, matching how the
CI section already wrote them, so they cannot go stale again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:27:21 +03:00
mixeme f11fee10e9 docs: sync TESTS.md with the suite it describes
The document had drifted well past the point of being useful as an index:
it listed 130 tests against 170 in the tree, described four test files that
were never added to it at all (domain/config_test.go, runner/seed_test.go,
ui/layout_test.go, ui/theme_test.go), attributed runner's Windows
invocation tests to the wrong file, kept two tests that no longer exist
(TestParseRegistryRunValue, TestLinuxAutostartRemovesLegacyDesktopEntry),
and filed three activity-panel tests under jobs_view_test.go when they live
in history_view_test.go.

Most of the gap is the work of the last few releases: the per-job timeout's
three states, the persisted pause and jobs-list density, SeedStats, and the
whole GUI-geometry set the layout review produced.

Three claims were false rather than merely missing. The ui test files are
no longer "pure helpers, no Fyne widget construction" — they build views
under test.NewApp() and measure them, which is now stated as its own design
principle, because that measurement is what makes the STANDARDS rule about
theme-derived sizes enforceable. layout.go is no longer an open coverage
gap. And the coverage-gap list now names the real remaining one: Fyne's
headless driver cannot report a maximized window, which is the reason
window-size persistence is frozen.

Verified mechanically: every test function in the tree appears exactly once
in the document, under the file it actually lives in, and the document
names no test that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:24:27 +03:00
mixeme bde9a2e33e release: 1.0.0, and record the details pane's width coupling
The 0.16.0 release was never tagged or pushed, so what it described ships
as 1.0.0 instead: the changelog section is renamed rather than followed by
an empty one, and there is no 0.16.0 for anyone to have seen.

The last remainder of the layout review's F9 goes in with it. The value
column in captionValueLayout has no minimum of its own — it takes whatever
the container leaves after the caption — and what actually keeps it
readable is the 460 px minimum on commandOutputScroll, a constant that
exists for command output being legible. The dependency was invisible at
both ends; both now state it, so lowering that width is a decision rather
than an accident. The HSplit divider is the user's side of the same thing:
it is how the value column can be widened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:15:19 +03:00
mixeme c5f300b670 docs: close the GUI layout review, release 0.16.0
Stage 9 of the GUI layout plan: the roadmap item the review was raised
under is closed, so the plan and the findings document go with it — what
they established now lives in STANDARDS and the CHANGELOG.

STANDARDS gains the rule the review produced: a size that must follow the
theme is measured at build time, not written as a pixel constant, because
a hand-tuned number is only correct for the theme it was tuned against.
rowOverlap, captionColumnWidth, textColumnWidth, activityRowsHeight and
initialSplitOffset are the worked examples.

The CHANGELOG entry keeps to what the user can see: the window opens at
the size it asks for and drags smaller, the Jobs divider is draggable,
History columns hold their content on a scaled UI, and the Settings
button row and block spacing are as their layouts intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:42:20 +03:00
mixeme d0dc17a067 feat(ui): draggable divider between the jobs list and the details pane
The jobs view pinned the sidebar in a Border left slot, so it rendered at
its MinSize and nothing else: the user could never trade list width for
detail width, and the details value column had no escape from the width
the metadata grid left it.

container.NewHSplit replaces the Border. Either pane can now be dragged,
and container.Split clamps both at their own content minimum, so the
divider degrades a pane rather than clipping it.

The initial offset is derived, not a literal: SetOffset takes a ratio,
but the sidebar's natural width is absolute (448 px), and a ratio that
fits 1024 px would hand the sidebar 700 px at 1600 px. initialSplitOffset
divides that width by the default window width less the divider, which
opens the sidebar at exactly its content width with the rest to details.

The divider position is not persisted; a restart reopens at the computed
default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:40:17 +03:00
mixeme cebd41a5ac feat(ui): content-measured History columns, shared caption widths, settings_view split
Stage 5: generalize logColumnWidth into textColumnWidth so History's
Trigger/Job/State/Detail/Log columns size from measured text instead of
pixel constants that clipped at larger text sizes (F6, F14).

Stage 6: captionColumnWidth replaces detailCaptionWidth and
settingsLabelWidth with one theme-derived helper; jobs_view_details.go
now builds its metadata rows and their width from a single
metadataRows() list instead of two hand-kept ones (F10); the Settings
button row drops its transparent-rectangle spacers for a
CustomPaddedLayout (F8); the remaining eight fyne.TextTruncate call
sites move to the non-deprecated Truncation field (N1).

Stage 7: settings_view.go split into settings_view.go (field
construction/save/load/validate), settings_view_layout.go (the
two-column layout and settingsSection/settingsRow), and
settings_view_helpers.go (fyneVersion, dialogs, path helpers),
mirroring the jobs_view.go split. Along the way, Queue/Storage's inline
VBox and Application/About's settingsSection collapse into one
settingsSection(title, spacing, rows...) constructor, and
chooseFile/chooseJSONFile merge into one function with a filter
argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:26:13 +03:00
mixeme 57e6fe410e perf(ui): sort History once per redraw, drop duplicate jobs refreshes
Stage 4 of the GUI layout plan (F11, F12).

History: the cell callback copied and sorted the whole event list on every
call, and a full-window Refresh issues one call per visible cell — 126 sorts
of a 300-element slice per redraw, measured. The sorted snapshot now lives in
`rows`, refilled by `resort()` at build time, on a sort toggle, and from
`refresh()`. The length callback moves to `len(rows)` with it: cells and the
row count have to read the same slice, which was only incidentally true while
each cell re-derived the order for itself. The column captions become a
package-level array instead of a slice reallocated per header update, and the
per-cell `TextStyle`/`Refresh()` pair goes — the template already carries the
zero style and `SetText` refreshes.

Jobs: `refreshView()` already re-reads the service snapshot and refreshes the
list, so the six `list.Refresh()` calls that preceded it, and the duplicate
`syncFromService()` in the pause handler, were redundant. The folder filter's
early-return path never reaches `refreshView()`, so its `list.Refresh()` moves
into that branch rather than being deleted.

Both changes carry regression tests, each verified to fail against the
behaviour it guards: `TestHistorySortToggleKeepsRowsInSync` for the
cache-versus-length hazard, `TestToolbarButtonRedrawsRowAndDetails` for the
handlers that now rely on `refreshView` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:09:51 +03:00
mixeme 60aceb75af fix: window minimum, stock spacing layout, sidebar width floor
Part A stages 1-3 of the GUI layout cleanup plan:

- Stage 1 (F1-F3): delete settingsControlWidth's redundant wrapper (the
  Border centre slot already stretches controls), truncate the config
  path label, and name the default window size so it can be asserted
  against. Settings no longer widens the window past what it asks for.
- Stage 2 (F4-F5): drop compactVBoxLayout for the stock
  layout.NewCustomPaddedVBoxLayout and a single derived rowOverlap()
  spacing, replacing three hand-tuned spacing constants.
- Stage 3 (F7): delete the inert 400px sidebar width floor; the Border
  left slot already renders it at content MinSize.

See docs/PLAN-gui-layout.md.
2026-07-27 13:47:39 +03:00
mixeme 9d39f5c100 docs: implementation plan for the GUI layout review findings
Turns all fifteen findings from GUI-LAYOUT-REVIEW.md into nine staged commits,
each with the exact file changes, the tests it owes, and the measured result it
should produce.

Part A (stages 1-6) is the single-fix set: bring the window minimum under the
1024x660 the app asks for, retire compactVBoxLayout for the stock
layout.NewCustomPaddedVBoxLayout, delete the sidebar width floor, hoist the
History sort out of the per-cell callback, measure the History columns from
their content, and fold the caption widths into one theme-derived helper.

Part B (stages 7-8) is the roadmap-sized work: split settings_view.go and
replace the fixed Border sidebar with a draggable HSplit. Stage 9 closes the
ROADMAP item, adds the rule this review established to STANDARDS, and ships as
0.16.0.

Three decisions are settled and folded in: everything is in scope, so nothing
carries forward to ROADMAP; the split divider position is not persisted, which
keeps stage 8 inside src/ui; and the row spacing unifies on -8, the one
deliberate visual change in Part A.

Also records a recommended model per stage, split by judgement density rather
than diff size, plus the trap each stage carries - the History length callback
in stage 4 and the paired label change in stage 6 are the two that fail
silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 13:39:50 +03:00
mixeme 70aa4cbc4d docs: GUI layout review — findings for the composition pass
Carries out the "GUI review — custom layouts and composition" roadmap item and
records the result in docs/GUI-LAYOUT-REVIEW.md. Composition only; no code was
changed. Every number was measured with a throwaway headless Fyne probe at text
size 14 and 20, not estimated.

Headline finding: the Settings tab sets a minimum content width of 1165.5 px for
a typical install — wider than the 1024x660 window run.go asks for — so Fyne
silently widens the window on open and the user cannot drag it narrower. It also
grows with the length of the config-file path (1501 px for a 75-character one).
Two independent causes: the seven settingsControlWidth wrappers, which measure
identically to bare controls at every reachable width and only inflate MinSize,
and the Config JSON path label, the one value label in the tab without
truncation. Fixing both takes the floor to 993.3.

Also found: compactVBoxLayout is a byte-for-byte re-implementation of stock
layout.NewCustomPaddedVBoxLayout (identical geometry at every spacing tested);
minJobsSidebarWidth (400) never binds because the toolbar row already needs 448;
the negative row spacings are theme.InnerPadding() written as a magic number;
the History column widths truncate their own content on a scaled UI; and the
History table re-sorts its whole event slice once per cell — 126 sorts of a
300-element copy for one redraw.

Fifteen findings in all, each with a disposition (single fix or roadmap) and a
suggested order. fixedHeightLayout, which the roadmap singled out, is kept: no
stock layout forces an exact height, and the alternative depends on the parent
staying a Border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:03:50 +03:00
mixeme 721b049100 feat: select the jobs file itself in Settings
The Jobs directory row named a folder and assumed the file inside it was
called jobs.json. It is now a Jobs file row: Browse opens a file picker
filtered to .json, the field stays editable so a file that does not exist yet
can be typed, and the job list can live under any name.

Config.JobsDir/jobs_dir becomes Config.JobsFile/jobs_file, holding the whole
path; Paths.JobsDir is derived from it so saves still create the folder. An
older gosentry.json is migrated on load by joining its jobs_dir with
jobs.json — the exact file that version used — and the retired key is dropped
when the config is rewritten. The default clears before unmarshalling, or a
file that omits jobs_file and a file that sets it would be indistinguishable
and the migration would never run.

Saving used to write the current job list over whatever was at the new path,
which made switching to an existing jobs file impossible: its contents were
destroyed. An existing file now wins. Its jobs are loaded, normalized, and
adopted, with runtimes, schedule cache, next-run times and log-seeded
statistics rebuilt around them by adoptJobsLocked — the same helper NewService
now uses, so construction and adoption cannot drift. A path with no file
behind it still receives the current jobs, which is how the file is renamed or
relocated. The new file is read before anything is written, so an unparsable
one leaves both the config and the jobs untouched.

Adoption drops every runtime, and a run finishing afterwards would write its
result onto whichever job inherited its ID, so the switch is refused while a
job is running. Unrelated settings still save during a run. Because the
replacement happens without a prompt, the Service emits JobsLoaded with the
path and count, and History carries the receipt.

A path that names only a folder (trailing separator, a dot, or two dots) is
rejected with a validation error instead of failing later with an opaque OS
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:43:56 +03:00
mixeme 5a018d03cb docs: add cron import/export and GUI layout review to the roadmap
Two planned items larger than a single fix:

- Import/export jobs as a cron table, with the open questions that must be
  settled first: the job fields crontab has no slot for, "@every" not being
  valid crontab, splitting Command/Arguments per platform, which lines to
  skip on import, and merge semantics.
- A focused pass over the ui package's custom layouts and tuned constants —
  negative spacings that cancel widget padding, pixel sizes that ignore theme
  metrics, a layout with one call site, and settings_view.go past the size
  guideline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:20:11 +03:00
mixeme e5f8c7a812 docs: changelog and version bump for 0.14.0
Close the Unreleased section as 0.14.0 and fill the gaps in it: the Folder
caption moving onto the filter row, the padding around the Settings button
row, the Truncation-field refactor, the Docker build cache mount, and the
review/standards documents added since 0.13.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:11:31 +03:00
mixeme 5e09ba1d58 feat: open the logs folder from the Settings tab
Reading a log file meant copying the configured path out of Settings and
pasting it into a file manager. The Logs directory row now carries an Open
button beside Browse that reveals the folder directly.

The new src/platform/filemanager package holds the platform split — explorer
on Windows, xdg-open on Linux, an "unsupported" error elsewhere — and starts
the handler without waiting on it, since Explorer exits non-zero even after it
opens the window and blocking would stall the UI thread. A missing path, a
path that is a file, and a handler that will not start are all reported to the
user; the logs directory does not exist until the first run, so that case is
reachable.

The button opens whatever the field currently holds rather than the saved
config, so an edit can be checked before Save. Resolving a relative directory
against the application folder is the store's rule, so resolveConfiguredDir is
now exported as storage.ResolveConfiguredDir instead of being duplicated in
the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:09:33 +03:00
mixeme fe13d1f34e feat: make the Arguments placeholder state the field's rule
The hint was a lone example path, which left the one-argument-per-line
convention to guesswork. It now names the rule and shows a flag plus a
path containing a space, so the absence of quoting is visible too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:58:37 +03:00
mixeme 7aa1421639 feat: put the Folder caption on the filter row
The caption sat on its own line above the select, costing the sidebar a full
label of height before the job list started. It now occupies the border
layout's left slot, next to the select and the view toggle, so the whole filter
is one row and the list begins a line higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:51:48 +03:00
mixeme 4d014e2f45 docs: add a review agenda and config compatibility rules
Reviewing the project used to mean re-stating what to look at every time.
docs/REVIEW.md now holds that agenda once — nine areas, each anchored to this
codebase — and both entry points point at it rather than copying it: the
/review-project command in .claude/commands, and a section in CLAUDE.md so a
plain-language review request lands in the same place.

STANDARDS.md gains a "Config file compatibility" section. The project has
applied the same rule three times (Theme, JobListView, TimeoutSeconds) without
ever writing it down: a new Config field is omitempty and its zero value means
the previous behavior, a meaningful zero is never backfilled on load, and an
unrecognised enum value reads as the default through one shared helper. With no
migration step and hand-editable files, that is what keeps older configs working.

Also removes docs/PLAN-compact-job-list.md, implemented in edabc57 — everything
but the version bump, which now waits for the release along with the rest of
the Unreleased section. .claude/settings.local.json is ignored so the shared
command can be tracked without per-developer permissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:44:30 +03:00
mixeme 706aa8e6ba refactor: truncate job names via the non-deprecated Truncation field
fyne.TextTruncate is deprecated in Fyne 2.7.4 in favour of the widget's
Truncation field. Behaviour is unchanged: a long job name is clipped rather
than widening the row, which is what keeps the compact row's status pinned to
the right-hand edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:44:30 +03:00
mixeme bfb982cc27 docs: add CLAUDE.md pointing at the standards document
Agent sessions did not pick up docs/STANDARDS.md automatically. CLAUDE.md is
loaded every session, so it links the standards, architecture, and test docs,
repeats the few rules most often broken, and records the CGO/MSYS2 build
prerequisite and the commit-to-main convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:44:30 +03:00
mixeme 29ce94c3e8 feat: add a compact job list view to the Jobs tab
Each job can now render as a single line — name on the left, status on the
right — instead of the three-line block, so many more jobs fit without
scrolling. A toggle button beside the Folder filter switches between the two
modes and is labelled with the action it performs, matching the existing
"Disable auto" convention.

The choice is persisted as Config.JobListView ("detailed" / "compact", stored
as job_list_view in gosentry.json). Empty, legacy, and unrecognised values all
normalize to detailed, so existing installs keep the current look and the file
never gains a value no reader understands.

Selection, the details panel, the folder filter, and live status updates work
unchanged in both modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:44:30 +03:00
mixeme e85cbc4eb1 feat: make per-job timeout 0 mean "no timeout" instead of inherit
A per-job timeout now has three distinct states: unset inherits the global
default, an explicit 0 means no timeout and does not inherit, and a positive
value is the per-job limit. Job.TimeoutSeconds became *int so unset and 0 stay
distinguishable in jobs.json.

Also fixes the global default, which could not persist a 0. loadOrCreateConfig
normalized DefaultTimeoutSeconds <= 0 back to 30 on every read of an existing
gosentry.json, so "no timeout" only held until the next restart. The field is
now written unconditionally (no omitempty) and read back as-is.

Existing jobs and configs are unaffected: a job with no timeout_seconds still
inherits, and a saved global default of 30 stays 30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:44:30 +03:00
mixeme 7f4f63eb8e docs: switch job list view control to a toggle button
The plan now uses a button that flips its own text and icon (Compact +
ListIcon / Detailed + ViewFullScreenIcon), mirroring the existing
stopAllButton idiom, instead of a "View" dropdown. It sits beside the folder
filter on the same row via a border layout, so the sidebar header keeps its
current height and the toolbar row is untouched.

The label helpers become nextJobListView and viewToggleText, with tests and
the verification steps updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:54:20 +03:00
mixeme c58fe0d332 docs: add implementation plan for compact job list view
Plans an opt-in one-line rendering of the Jobs tab list (name left, status
right) alongside the current three-line detailed rows, switched from a "View"
dropdown beside the Folder filter and persisted as a new Config.JobListView
field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:07:06 +03:00
mixeme 88511843ce feat: add spacing around Settings tab button row
The Save/Cancel/Defaults row sat flush against the separator above it
and the tab's left edge, tighter than the other vertical gaps in the
tab. Add matching padding spacers to bring it in line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:06:30 +03:00
mixeme e87840e95b build: persist Go build cache across Docker release builds
Mount .gocache/ from the host into the builder container so --rm
container removal no longer wipes GOCACHE between runs.
2026-07-26 13:58:26 +03:00
mixeme 33a246cd13 feat: make global default timeout 0 (infinite) instead of required 30s
DefaultTimeoutSeconds now means "no timeout" when 0/empty, and that is
the new default, rather than an invalid config forcing a positive
value. runner.RunJob avoids context.WithTimeout with a zero duration
(which would expire immediately) and instead runs on a plain
cancelable context when no timeout is configured. Per-job
TimeoutSeconds inherit semantics are unchanged.
2026-07-26 13:57:03 +03:00
mixeme 3992b40eda docs: changelog and version bump for 0.13.0
Documents the branded GoSentry theme and Settings Cancel/Defaults buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme 2c684532da feat: make GoSentry light theme boldly branded
Give the light variant a soft teal window canvas with white inputs,
menus, dialogs, and buttons on top, plus teal-tinted separators, input
borders, and table headers. Cards and fields now lift off a branded
background instead of reading as a plain accent swap on gray. Text stays
dark (delegated to the base foreground) for high contrast on both the
teal canvas and the white surfaces.

Lock the teal canvas and white input background into the theme tests so
the light look cannot silently regress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme d3e3d30b42 feat: add Cancel and Defaults buttons to Settings
Cancel discards unsaved edits by reloading the saved config; Defaults
loads built-in default values into the form for review before saving.
Extracts the default config into domain.DefaultConfig() so the store
and settings UI share one source of truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme 84212764e4 feat: selectable branded GoSentry color theme
Add a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants, and let the user choose between it and Fyne's default theme
from Settings. The dark variant uses deep-teal surfaces to echo the app
icon.

The choice is persisted as a new Config.Theme field ("default" /
"gosentry"), applied at startup before the first frame and live-previewed
when picked in Settings. Empty/legacy configs normalize to the default
theme so existing installs keep the original look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:26:09 +03:00
mixeme eedae3b9f0 docs: use concrete mixeme/gosentry coordinates in update-check item
Replace the <owner>/<repo> placeholders with the actual GitHub release repo
now that it's known.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:23 +03:00
mixeme bfd9991a3a docs: roadmap item for GitHub release update check
Plan a best-effort, opt-in check that compares app.Version against the
latest published GitHub Release and surfaces an "Update available" hint in
Settings — detection and notification only, no auto-download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:39:54 +03:00
mixeme 2ab5f07c7c docs: remove implemented per-job timeout plan
The per-job command timeout is now shipped (0.12.0); its ROADMAP entry is
gone, so the implementation plan is no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:59 +03:00
mixeme 48faddb3bd feat: per-job command timeout with global default
Add an optional per-job run timeout following the overlap_policy inherit
pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new
Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s
guard in runner.RunJob.

- domain/storage: new fields, default 30, load-time normalization
- runner: RunJob takes an explicit timeout; StartOnly stays untimed so it
  keeps measuring launch latency only
- app: effectiveTimeout resolves under mu into runEnv, threaded to runJob;
  seam signature and validation updated; DisplayTimeout helper
- ui: Timeout entry in the job dialog, Default timeout in Settings, and a
  Timeout row in the details panel
- tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated;
  version bumped to 0.12.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:24:50 +03:00
mixeme f4221f6ce4 docs: clarify supported platforms (Windows/Linux; macOS unsupported)
Add a Platforms section to the README stating that autostart and desktop
integration are implemented only on Windows and Linux; macOS may build the
GUI but those features are stubbed. Avoids over-promising ahead of 1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:12:34 +03:00
mixeme b3eb429fa6 docs: add per-job timeout implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:29 +03:00
mixeme 40b609a7d4 docs: add logo to README header
Theme-aware <picture> swaps the dark wordmark variant under
prefers-color-scheme: dark so it stays legible on GitHub's dark theme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:59:05 +03:00
mixeme 3a68c705c6 assets: add GoSentry wordmark logo (clock-dial variant)
Recommended variant from gosentry-logo.html: Space Grotesk SemiBold with
an amber "G", a clock-dial "o" (ring + hands), and petrol "Sentry". Ships
self-contained SVGs (glyphs outlined to paths, no font dependency) in
light/dark/mono plus transparent PNGs at 256-2048px, with a README and the
gen_logo.py/raster.py generators for reproducibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:51:38 +03:00
mixeme 5d662c7fd5 ci: trigger release build on published release instead of tag push
Both workflows now run on `release: published` and attach the built
archives to the release the user creates, rather than pushing a tag and
having the workflow create the release. Tag comes from the release event;
GitHub no longer regenerates release notes. Docs updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:39:59 +03:00
mix d4ca38b45a Create gosentry-logo.html 2026-07-18 14:28:20 +03:00
mixeme 48622a99c2 ci: install Node.js in Codeberg container before JS actions
The golang:1.22-bookworm container has no node binary and Codeberg's
runner does not inject one, so checkout/forgejo-release failed with
'node: not found'. Add a pre-checkout run step (which executes via the
container shell and needs no node itself) to apt-install nodejs before
the JS-based actions run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:58:40 +03:00
mixeme 9866673bab ci: use codeberg-medium-lazy runner tag for Forgejo release build
Codeberg's hosted runners are tagged codeberg-tiny/small/medium (+ -lazy),
not 'docker', so the job was never picked up ('No active runner with tag
docker'). The three CGO cross-compiles exceed the non-lazy 10 min cap, so
use the medium -lazy runner, which relaxes the wall-clock limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:50:38 +03:00
mixeme 2f965166ba ci: add GitHub and Codeberg release build workflows
Add tag-triggered CI that builds and publishes the linux/amd64,
linux/arm64, and windows/amd64 release binaries on both forges.

- scripts/ci-build-release.sh: shared, Docker-free build+package for all
  three targets (Windows cross-compiled via MinGW), reused by both
  workflows so the build logic lives in one place.
- .github/workflows/release.yml: GitHub Actions, publishes via
  softprops/action-gh-release using the built-in token.
- .forgejo/workflows/release.yml: Forgejo Actions for Codeberg, publishes
  via forgejo-release using a RELEASE_TOKEN secret.
- .gitattributes: force LF on workflow YAML so bash run: blocks don't
  break on Linux runners.
- docs/DEVELOPMENT.md: document the tag -> release flow and token setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:32:07 +03:00
mixeme e9ec48bf15 docs: remove <br/> from mermaid labels to fix Gitea rendering
The prior fix switched \n to <br/> in node labels, but Gitea's mermaid
renderer still emits unclosed <br> tags in the generated SVG regardless
of source syntax. Use single-line labels instead to avoid <br> entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 08:09:22 +03:00
mixeme 2c38941f6e docs: fix mermaid diagram XML parse error in Gitea rendering
Gitea's SVG renderer requires well-formed XML; mermaid's \n line
breaks produce unclosed <br> tags in foreignObject labels. Use
explicit <br/> instead.
2026-07-08 07:49:22 +03:00
mixeme effd740e45 docs: add descriptions and missing docs to README documentation list 2026-07-08 07:43:07 +03:00
mixeme 8bd50d0431 docs: credit Claude Code in development assistance note 2026-07-01 23:24:40 +03:00
mixeme 5c89d5ccf1 Update ROADMAP.md 2026-07-01 23:20:13 +03:00
mixeme 9216f01bf6 release: bump version to 0.11.5
Replace interim FUTURE_WORK.md with slim STANDARDS.md and update doc links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 23:16:24 +03:00
mixeme aed83b91b9 docs: add FUTURE_WORK quality standard and close polish gaps
Replace CODE_REVIEW.md with a living maturity checklist, document
session-only History, inject Service into newMainView for testability,
add UI and scheduler regression tests, and fix RunNow error surfacing
plus empty jobs view handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 23:14:09 +03:00
mixeme eba7bff17a release: bump version to 0.11.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:05:47 +03:00
mixeme 6ea6578acb feat: record launch latency in statistics for start-only jobs
StartOnly jobs previously forced DurationMS to 0 because GoSentry does
not wait for the process to exit, leaving the Statistics line stuck at
"last 0 ms, avg 0 ms". The runner already measures launch latency (time
to spawn the process) for the History detail; this now returns that
value as the run duration so the existing duration-driven stats pipeline
folds it into last/avg/max.

Sub-millisecond launches still round to 0 and are excluded from the
average, matching prior behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:05:17 +03:00
mixeme 00991f24a5 release: bump version to 0.11.3
Document the code-review reliability fixes in CHANGELOG.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 21:56:38 +03:00
mixeme 29d2ffed8f fix: complete code review follow-ups for queue, stats, and docs
Replace overlap Pending flag with PendingRuns counter, match seed stats
by job_id, align average duration with TimedRunCount, and tidy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 21:54:44 +03:00
mixeme e9fc9eaba0 fix: harden run persistence and error surfacing from code review
Snapshot store paths under lock before async runs, roll back failed
start/save state, emit UI events only after successful persistence,
surface log write failures, and sync stale YAML docs to JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 21:33:50 +03:00
mixeme 09c5edc993 docs: document disadvantages of platform-specific maximized detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:28:40 +03:00
mixeme 76c8ac92d1 freeze window size persistence until maximized detection is available
Saving w.Canvas().Size() when the window is maximized persists the maximized
dimensions, corrupting the stored size for the next launch. Commented out the
save calls until per-OS maximized detection (IsZoomed / _NET_WM_STATE /
NSWindow.isZoomed) is implemented. Roadmap entry updated to reflect frozen
status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:26:35 +03:00
mixeme dccb598b8c docs: add roadmap item for window size not saved when maximized
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:22:22 +03:00
mixeme 0c1ab8dd7e Update README.md 2026-06-25 23:00:58 +03:00
mixeme dda128ff85 Update README.md 2026-06-25 22:59:29 +03:00
mixeme 7ef00a1139 Update README.md 2026-06-25 22:58:14 +03:00
mixeme 34a702bc42 docs: add screenshots to README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:56:04 +03:00
mixeme 74e9e93bd7 Add screenshots 2026-06-25 22:55:06 +03:00
mixeme 882fcfe65d chore: release 0.11.2 with window persistence, appID update, and doc fixes
- Persist window size on quit/close and restore it on next launch
- Update appID from ru.mixdep.gosentry.desktop to ru.mixeme.gosentry.desktop
- Use markdown headers for Build sections in DEVELOPMENT.md
- Remove stale go.yaml.in/yaml/v4 dependency from DEVELOPMENT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:50:28 +03:00
mixeme 8cfa16a2bd docs: add History column filters to roadmap (pending Fyne support)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:36:03 +03:00
mixeme 5f36c7a0e1 feat(ui): rename pause/resume all to Disable/Enable auto with pause icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:30:30 +03:00
mixeme fb2f9b6e30 fix(ui): restore History column sort after Fyne 2.7 header-tap change
Fyne 2.7.4 made Table.Select() reject cells with Row < 0, so OnSelected
was never fired for header clicks and the Time sort toggle stopped working.
Replace the plain Label header with a custom historyHeader widget that
implements fyne.Tappable; UpdateHeader wires OnTapped for col 0 directly.
Also swap "asc"/"desc" text for ▲/▼ glyphs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:24:59 +03:00
mixeme 0e258979d9 chore: release 0.11.1 with settings spacing, label, and Save-button fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:50:10 +03:00
mixeme f53447d3e8 feat(ui): align settings spacing, fix label clipping, gate Save button
- Give the Queue selects the same default spacing as the Storage fields
- Widen the settings caption column so "Default overlap policy" fits
- Disable Save until a field differs from the saved config, re-enabling on
  change and re-disabling after a successful save

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:49:03 +03:00
mixeme 24a8140e26 chore: release 0.11.0 with paused manual runs and two-column layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:52:10 +03:00
mixeme 9ecb8b61f8 feat(ui): two-column settings/details, compact job list, manual run while paused
- Allow manual "Run now" while the scheduler is paused: pause now stops only
  automatic scheduled runs (RunDue), not the user's explicit action. Drop the
  paused guard in Service.RunNow and the UI pause dialog; update tests.
- Cap the details metadata caption width via a new captionValueLayout so a wider
  window feeds extra space to the value column instead of the short caption.
- Reorganize the Settings tab into two columns (Application+Queue / Storage+About)
  with Save spanning the full width; move the Autostart status onto its own line.
- Condense the Jobs list rows with compactVBoxLayout to fit more jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:51:03 +03:00
mixeme 43809e5076 fix(ui): refresh activity panel when switching selected job
The "Selected job activity" list kept showing the previous job's entries
when a different job was selected. dp.update() reassigned the backing
slice but never refreshed the widget.List, and only the refreshView path
appended an explicit refresh. Move d.logs.Refresh() into update()/clear()
so every caller redraws the panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:34:19 +03:00
mixeme 8b81180c92 chore: release 0.10.2 with condensed panels and settings spacing fix
Bump version to 0.10.2 and document the UI density/resizing work in the
changelog. Also restructure the settings form into sections so separators
and the editable Storage fields keep normal spacing (dividers no longer
crowd the row above, entry boxes stay visibly separated) while the
label-only sections remain condensed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:41:02 +03:00
mixeme 21d78ea010 feat(ui): condense panels and let the window shrink to 720p
Make the job details and settings views more compact and remove the tall
minimum that prevented resizing the window shorter:

- Lay job metadata out in two columns and stack rows with a negative-gap
  compactVBoxLayout, roughly halving the block height.
- Wrap the settings form in a vertical scroll so it no longer dictates
  the window minimum (AppTabs sizes to the tallest tab), and tighten its
  rows with the same compact layout.
- Shrink the command-output scroll minimum height so the details pane can
  get shorter; long output still scrolls.
- Size the "Selected job activity" panel to exactly maxJobActivityRows
  using widget.List's own content-height formula, so all three rows show
  without a scrollbar regardless of theme or DPI.

Together these drop the minimum window height from ~891px to ~570px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:34:47 +03:00
mixeme 5cdb9414b2 docs: update changelog for 0.10.1 build script improvements
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 23:05:22 +03:00
mixeme 9394bd66b8 chore: add informative messages to Windows build script
Add echo statements at key build steps to indicate progress:
- Build start and version info
- Output path
- Environment setup
- Icon resource embedding (or warning if windres not found)
- Go compilation
- Success completion message

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 23:04:42 +03:00
mixeme ec19ab3884 chore: bump version to 0.10.1 and update changelog
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 23:02:45 +03:00
mixeme 999ccb7300 refactor: unify icon asset naming to use size-based scheme
Rename icon files from mixed naming (big/16x16) to consistent size-based
names (large/small) for clarity. Update Go functions and variables to
reference the new asset names. This makes the icon pipeline easier to
understand and maintain.

File changes:
- gosentry-icon-big.png → gosentry-icon-large.png
- gosentry-icon-16x16.png → gosentry-icon-small.png
- gosentry-icon-16x16.ico → gosentry-icon-small.ico
- Embedded variable: iconBytes → iconLargeBytes for clarity

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 23:02:06 +03:00
mixeme 88989b143b docs/CHANGELOG: complete the 0.10.0 entry
Document the shipped work missing from the entry: per-job overlap
policy, persisted global pause, 720p-safe window sizing, portable
packaging helpers, and the internal cleanup (jobs_view split, dropped
YAML migration, planning-doc removal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:59:00 +03:00
mixeme 03cba5b5aa docs: drop completed release-milestone docs; trim roadmap
Remove RELEASE-0.10-PLAN.md and RELEASE-0.10-TASKS.md now that the
0.10.0 milestone work is finished (history preserved in git). Trim
ROADMAP.md to open items only; completed work lives in CHANGELOG.md.
Version stays 0.10.0 — this release is not yet tagged/shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:57:15 +03:00
mixeme 74cd7485ee docs: mark Release 0.10.0 complete; update roadmap (T8.1)
ROADMAP.md now documents that T6.1 (jobs_view split), T6.2 (cleanup),
and T7.1–T7.2 (Windows zip + Linux tar.gz packaging) are complete.
Version 0.10.0 and CHANGELOG.md were already finalized.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:51:03 +03:00
mixeme 1085667d7b scripts: add portable packaging helpers for Windows and Linux (T7.1-T7.2)
package-windows.bat builds gosentry.exe then bundles it with README and
CHANGELOG into a versioned .zip via PowerShell Compress-Archive.
package-linux.sh does the same for linux-amd64 and linux-arm64 (arm64
cross-compiles via aarch64-linux-gnu-gcc; skips gracefully if absent),
producing .tar.gz archives with files at the archive root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:50:06 +03:00
mixeme 13f2779e1f refactor: split jobs_view, drop YAML migration, update docs (T6.1-T6.4)
T6.1: split jobs_view.go into three files — jobs_view_helpers.go (pure
helpers) and jobs_view_details.go (detailsPanel struct with widget
creation, update, clear, and container methods) — bringing jobs_view.go
from 459 to ~200 lines.

T6.2: remove stale YAML upgrade note from README; drop *.yaml from
.dockerignore.

T6.3: delete YAML shadow structs (yamlConfig/yamlJob/yamlJobsFile),
importYAMLConfig/importYAMLJobs, legacy path constants, and all
YAML-import tests; run go mod tidy to remove go.yaml.in/yaml/v4.

T6.4: refresh ARCHITECTURE.md — JSON storage references, new Key Domain
Concepts section (per-job overlap policy, run-time statistics + log
seeding, persisted pause flag, jobs_view split).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:42:42 +03:00
mixeme d09b6e182c ui: 720p-safe default window size + tighter layout minimums (T5.1)
Lower default from 1120×720 to 1024×660 so the window opens fully
visible on 1366×768 / 720p screens. Reduce minJobsSidebarWidth (480→400)
and commandOutputScroll min size (520×160→460×120) to match; the layout
engine enforces these as the natural minimum, keeping the UI usable at
the smaller default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:26:34 +03:00
mixeme 18dd9aeabb docs: mark Phase 4 tasks complete (T4.1–T4.4) 2026-06-24 22:23:24 +03:00
mixeme 80d13d0ac6 app/operations_test.go: persist-pause + rebuilt-service tests (T4.4)
TestSetGlobalPausePersistsToConfigFile reads gosentry.json back from disk
after each SetGlobalPause call and asserts the Paused flag is correctly
written and cleared. TestServiceRebuiltFromPausedStoreStartsPaused builds
a second Service from the already-updated store and confirms both RunDue
and RunNow are refused while the service is paused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:21:24 +03:00
mixeme 7e868d6f5b ui/jobs_view: init pause controls from persisted state (T4.3)
schedulerPaused, the scheduler-state label, and the Pause-all/Resume-all
button now read Config.Paused at construction time so they correctly reflect
a paused install on restart instead of always starting in the running state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:18:36 +03:00
mixeme b9505e83ab domain/config.go + app: persist global pause state across restarts (T4.1, T4.2)
Add Config.Paused bool so the scheduler's paused flag survives a restart.
SetGlobalPause now writes the flag into store.Config and calls SaveConfig;
NewService seeds s.paused from Config.Paused before computing first next-run
times, so jobs show "Scheduler paused" immediately at startup when paused.
Also add Paused to the yamlConfig shadow struct to keep the direct conversion
from domain.Config valid until the YAML import path is dropped in T6.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:16:43 +03:00
mixeme b0d9883627 app/run_test.go: per-job overlap policy override tests (T3.4)
Adds three tests for the effective-policy resolution:
- per-job "queue" overrides global "skip" (job marked Pending, re-runs)
- per-job "skip" overrides global "queue" (overlap dropped, no re-run)
- empty Job.OverlapPolicy inherits the global default

Also ticks Phase 3 complete in docs/RELEASE-0.10-TASKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:33:06 +03:00
mixeme 554ce2b93a ui/app: per-job overlap policy in job dialog and details panel
- job_dialog.go: add overlap-policy widget.Select with "(Use global
  default)" → empty, "skip", and "queue" options; pre-selects the job's
  current value; on save maps the inherit label back to empty string.
- app/format.go: add DisplayOverlapPolicy(job, globalPolicy) — returns
  the policy name when overridden, else "skip/queue (global default)".
- ui/jobs_view.go: add "Overlap policy" detail row updated in
  updateDetails using DisplayOverlapPolicy.
- settings_view.go: rename "Overlap policy" label to "Default overlap
  policy" to clarify it is the global default jobs can override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:05:50 +03:00
mixeme 3f3b977fb0 app/run.go: resolve effective overlap policy per job in RunDue
RunDue now resolves each job's effective overlap policy via
effectiveOverlapPolicy (job value when set, else Config.OverlapPolicy)
instead of reading the global policy once per tick. An empty
Job.OverlapPolicy inherits the global default, so normalizeJobs continues
to leave the field untouched rather than backfilling it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:58:51 +03:00
mixeme 8f75318e8c domain/job.go: add OverlapPolicy field for per-job run policy
Adds OverlapPolicy field to Job struct with json:"overlap_policy,omitempty"
tag. When empty, the field signals that the job should inherit the global
overlap policy setting from Config. Also updates yamlJob shadow struct to
maintain field-layout equivalence for YAML→JSON import compatibility.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 20:57:00 +03:00
mixeme ed81443d29 ui/history_view: size Log column to content so file names aren't truncated
The Log column was fixed at 240px with TextTruncate, clipping log file
names like 20260601-100000_<JobName>.log. Measure the widest value and
size the column to fit (bounded 240–520px), recomputing on refresh so
newly recorded events widen the column instead of being cut off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:54:23 +03:00
mixeme cf276fc8ec ui/jobs_view: cap Selected job activity to 3 recent rows, anchor at bottom
Show only the latest 3 activity entries for the selected job instead of the
full in-memory list, and pin the panel to the bottom of the details pane via a
Border so the command output absorbs the remaining vertical space.

Adds fixedHeightLayout (mirroring minWidthLayout) to reserve a stable height
for the activity list. The full per-job history (maxJobLogs) is untouched and
still available in the History view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:36:41 +03:00
mixeme 07c0995935 Update .gitignore 2026-06-24 08:32:18 +03:00
mixeme d13b90061c docs/RELEASE-0.10-TASKS: unmark T8.1 (ROADMAP + PERFORMANCE pending)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:27:12 +03:00
mixeme 65281c3996 docs/RELEASE-0.10-TASKS: mark T2.5 and T8.1 complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:26:40 +03:00
mixeme b45ab654c2 app/version, docs/CHANGELOG: bump to 0.10.0 (T8.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:26:03 +03:00
mixeme 886d0d9caa app/format, ui/jobs_view: DisplayStats summary + Statistics detail row (T2.6, T2.7)
Add DisplayStats to format.go: returns "No runs recorded" when RunCount is
zero, otherwise a one-line "N runs, M failed, last X ms, avg Y ms, max Z ms"
summary.  Wire a Statistics detail row into the jobs panel that refreshes via
updateDetails alongside the other runtime fields.

Tests: TestDisplayStats (nil/zero/normal/no-fail), TestUpdateStats (three
sequential fake runs through updateStats verifying all aggregate fields), and
five seed tests in runner/seed_test.go covering basic aggregation,
duration-less legacy logs, maxFiles capping, missing directory, and
unmatched log files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:24:48 +03:00
mixeme 6c69f323bd runner/seed, app/service: seed run-time stats from log files (T2.5)
Add SeedStats, which reconstructs per-job execution-time aggregates from
existing log files: suffix-matched by sanitized job name, bounded by
MaxLogFiles, and tolerant of duration-less legacy logs (counted in
run/fail totals but excluded from last/avg/max). NewService folds the
seed into each JobRuntime at build time so stats survive a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:16:47 +03:00
mixeme 7dafa82b21 domain/runtime, app/run: add execution-time statistics aggregate (T2.4)
Add RunCount, FailCount, LastDurationMS, AvgDurationMS, MaxDurationMS
to JobRuntime and fold each completed RunRecord into them via updateStats
called from executeRun.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:13:02 +03:00
mixeme 2b0cdb57f4 runner/logfile: write duration line to log header (T2.3)
Pass durationMS to writeRunLog and include it as 'duration' in the
log header for persistence.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 08:11:05 +03:00
mixeme 42d73b9eb0 domain/record, runner: add DurationMS to RunRecord (T2.1, T2.2)
Add DurationMS int64 field to RunRecord and measure wall-clock
start→finish in RunJob; StartOnly jobs record 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:09:59 +03:00
mixeme 183ea1b125 docs/RELEASE-0.10-TASKS: mark Phase 1 complete (T1.1-T1.4) 2026-06-24 07:55:55 +03:00
mixeme 61ea3ce2af app/format_test: add EventLine test cases (T1.4)
Cover the compact formatter with three scenarios:
- Log file present: shows base name only (not full path)
- Log file absent: formats without a log column
- Empty trigger: defaults to "Unknown"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 07:52:53 +03:00
mixeme 0f27d75359 ui/jobs_view: render job logs as compact one-line entries (T1.2)
Switch the jobLogs list template to TextTruncate wrapping and use
EventLine (base log filename only) instead of EventText so each
activity row stays on a single line in the jobs panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 07:50:03 +03:00
mixeme 8fc28f592d app/format: add EventLine compact formatter (T1.1)
Uses filepath.Base for the log file so the jobs log view can show
one-line entries without the full directory path cluttering the row.
EventText is kept intact for the History table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 07:49:26 +03:00
mixeme adb119973a Add persisted Pause-all state to 0.10.0 plan and tasks
New feature section: store the global pause in Config so "Pause all"
survives restarts (config field, persist in SetGlobalPause, init service
and UI from it). Renumber the later plan sections and task phases to fit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:04:29 +03:00
mixeme 4226cb6141 Add 0.10.0 task list with model and thinking depth
Execution checklist for RELEASE-0.10-PLAN.md: 7 phases of tasks each
tagged with recommended model and thinking depth, plus a completion
checklist and definition of done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:53:42 +03:00
mixeme bf9c1517a5 Add YAML->JSON migration cleanup to 0.10.0 plan
Section 6 now includes dropping the one-time YAML import that shipped in
0.9.0: the shadow structs and import path in storage, the legacy path
names, the go.yaml.in/yaml/v4 dependency, the import tests, and the
*.yaml ignore rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:51:51 +03:00
mixeme 55e90d80d3 Refine 0.10.0 plan: keep startup timing, add architecture-doc update
Section 6 now preserves the startup-timing instrumentation through the
post-field-test cleanup and adds a task to refresh docs/ARCHITECTURE.md
for the per-job overlap policy, run-time statistics, and jobs_view split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:49:40 +03:00
mixeme 20390fe476 Add 0.10.0 milestone plan; trim ROADMAP to portable packaging
Add docs/RELEASE-0.10-PLAN.md covering the open roadmap follow-ups plus
per-job overlap policy, run-time statistics, one-line activity entries,
log-name fix, and 720p window sizing. Drop the non-portable delivery
formats (.deb, installer, AppImage, Flatpak, winget) from ROADMAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:46:23 +03:00
mixeme 67cf4e1c11 Create RELEASE-0.10-PLAN.md 2026-06-24 00:43:24 +03:00
mixeme 93b363d802 Remove completed items from ROADMAP
Drop the resolved Tray Interaction section (0.9.0) and the completed
run.go split note; keep pending follow-ups, cleanup, and packaging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:36:25 +03:00
mixeme 41e2089142 Remove completed pre-release planning docs
All 8 phases of the pre-release milestone are done and committed; the
plan and task checklist are no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:33:51 +03:00
mixeme dafd8ef345 P8.3: update CHANGELOG and ROADMAP for 0.9.0 release
CHANGELOG:
- Expand 0.9.0 entry to cover all pre-release work: JSON storage
  migration, exit-code removal, PySentry legacy cleanup, queue execution
  modes/overlap policies, failure notifications, Command browse button,
  developer docs split, icons, tray left-click, Fyne 2.7.4 upgrade.

ROADMAP:
- Remove resolved "Linux test build broken" item (fixed in P7.1).
- Note that app/run.go was created to split dispatch logic.
- Mark "Tray Interaction" section as resolved in 0.9.0; document the
  SetSystemTrayWindow implementation and re-measured startup time
  improvement (−36%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:30:34 +03:00
mixeme 2e54d088bf P8.2: rewrite README.md for end users
- Update Features to reflect JSON storage, queue execution mode/overlap
  policy, failure notifications, and tray left-click-to-show.
- Replace Documentation project-notes list with a proper Documentation section.
- Rewrite Storage section: JSON file examples with all current fields
  (execution_mode, overlap_policy); add YAML upgrade note.
- Add Queue Settings section documenting parallel/sequential and skip/queue.
- Add Notifications section.
- Fix all 0.3.0 version strings to 0.9.0.
- Remove Codex attribution line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:28:49 +03:00
mixeme 2b636cbb65 P8.1: extract developer docs into docs/DEVELOPMENT.md
Move Requirements, Build, Run From Source, Project Layout, and
Dependencies/mirroring out of README into docs/DEVELOPMENT.md. Update
version references to 0.9.0 and correct the storage description to JSON.
README retains user-facing sections and gains a Development link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:26:36 +03:00
mixeme 3907ab3c18 P7.1: move Windows-only runner tests into runner_windows_test.go
Tests touching SysProcAttr.HideWindow/CmdLine and windowsShellCommandLine
are guarded by //go:build windows; runner_test.go drops the winproc import.
Linux cross-compile and Windows test run both clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:23:25 +03:00
mixeme a446f67293 Delete REFACTORING.md 2026-06-24 00:20:04 +03:00
mixeme 0b8c340f6e Bump version to 0.9.0; update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:46:57 +03:00
mixeme 646bfc734d P6.3: re-measure with GOSENTRY_TIMING instrumentation; correct figures
Warm-run average (runs 2-5 of 5) via env-gated phase timers in run.go:
  after Fyne app + window + tray  193 ms  (was 285 ms on Fyne 2.6.3)
  after newMainView               249 ms
  after SetContent                252 ms
  after w.Show() [TOTAL]         ~414 ms  (was ~644 ms, -230 ms / -36%)

Instrumentation reverted before this commit; source is clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:38:28 +03:00
mixeme c57d5b102f P6.3: correct startup measurement — ~400 ms on Fyne 2.7.4
Previous commit used PowerShell process-launch timing instead of the
History tab metric. Actual warm-run figure from the in-app "Window shown
in …" event is ~400 ms, down from ~644 ms on Fyne 2.6.3 (~37% recovery).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:36:04 +03:00
mixeme f03f012c1b P6.3: measure startup on Fyne 2.7.4; append to PERFORMANCE.md
Measured warm-run startup (launch to window creation) via PowerShell Stopwatch
across 3 runs: ~1215 ms average. Added finding for 2026-06-23 noting the
measurement method and that Fyne 2.7.4 builds cleanly with no breaking changes.
For higher-precision measurement, the GOSENTRY_TIMING instrumentation pattern
from the previous finding can be reused.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-23 22:32:53 +03:00
mixeme 26f58abda6 P6.2: left-click tray icon shows window via SetSystemTrayWindow
Calls desk.SetSystemTrayWindow(w) after SetSystemTrayMenu so a left-click
on the tray icon shows/focuses the window without opening the menu. The
explicit "Show" menu item is kept for right-click access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:27:37 +03:00
mixeme df217fdf31 P6.1: confirm Fyne 2.7.4 upgrade; mark done
go.mod already pins fyne.io/fyne/v2 v2.7.4 (latest 2.7.x). Verified
go mod tidy is a no-op and rebuilt under MSYS2 UCRT64/CGO: build, vet,
and go test -race ./... all green with no code changes. 2.7 changelog
review found no breaking changes affecting GoSentry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:25:45 +03:00
mixeme 8b05d0cfd1 P5: mark Phase 5 icons done; reflect size-appropriate approach
Tick P5.1 and P5.2 in the completion checklist and reword the P5.2 task line
to match what shipped in f533739: size-appropriate, transparent per-platform
icons (tray uses the small icon; Windows window/taskbar draws from the
multi-size gosentry.ico via the GLFW_ICON resource; Linux titlebar uses
IconSmall) rather than "window icon stays large".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:14:28 +03:00
mixeme f533739d6e P5: per-platform transparent, size-appropriate window/tray icons
Give the window titlebar, taskbar, and tray a size-appropriate icon with a
transparent boundary on both Windows and Linux, instead of scaling one PNG to
every size.

Assets:
- Regenerate the PNGs and gosentry.ico with feathered color-to-alpha so the
  rounded-tile boundary is transparent; a binary white-key had left an opaque
  halo that read as a border on dark taskbars/trays.
- Rebuild gosentry.ico as multi-size (16 hand-tuned + 32/48/256 from big) and
  add a single-frame 16x16 gosentry-icon-16x16.ico for the Windows tray.
- assets.go: add IconSmall() and IconSmallICO().

Wiring:
- Windows window/taskbar: embed gosentry.ico under the GLFW_ICON resource and
  skip a.SetIcon so GLFW selects the right frame per size (hand-tuned 16 for the
  titlebar, a larger frame for the taskbar).
- Windows tray: SetSystemTrayIcon(IconSmallICO()), a 16x16 ICO frame.
- Linux window titlebar: a.SetIcon(IconSmall()) for a crisp ~16px _NET_WM_ICON;
  tray uses the big PNG since StatusNotifierItem renders larger.

Also bump Fyne 2.6.3 -> 2.7.4 (systray 1.11.0 -> 1.12.1) and document the full
cross-platform icon strategy in assets.go, gosentry.rc, run.go, and tray.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:09:04 +03:00
mixeme 6136bb645b Bump version to 0.8.0; update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:20:29 +03:00
mixeme 73dae9e64c P4.2: Add Browse button for Command field in job dialog
Add chooseFile helper (dialog.NewFileOpen) in settings_view.go and wrap
the Command entry in job_dialog.go with a Browse button so users can
pick an executable from a file picker instead of typing the path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:19:40 +03:00
mixeme 564609d8fb P4.1: Wire desktop notifications for failed job runs
Add Service.ShouldNotifyOnFailure() (reads config under mu) and call
fyne.CurrentApp().SendNotification in the mainwindow subscriber when a
Manual/Schedule run completes with State == "Failed" and the setting is on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:18:12 +03:00
mixeme fb9d0650a6 Bump version to 0.7.0; update changelog
Phase 3 complete: execution modes and overlap policies for job dispatch.

- Bumped version from 0.6.0 to 0.7.0 in src/app/version.go
- Added 0.7.0 entry to docs/CHANGELOG.md documenting parallel/sequential
  execution modes and skip/queue overlap policies, and their interaction
  with the Settings UI and manual runs.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-23 08:05:03 +03:00
mixeme 6b8f9a4ccf P3.5: Add queue dispatch tests
New src/app/run_test.go exercises the execution mode + overlap policy
dispatch paths added in P3.3, reusing the fake runJob seam:

- parallel mode starts all due jobs concurrently
- sequential mode serializes due jobs one at a time
- skip overlap policy drops a re-fire while running (no Pending)
- queue overlap policy marks Pending and re-runs after the run finishes
- RunNow refuses a manual run while another job runs in sequential mode

Helpers prime NextDue to the past and tick at the wall clock so a started
job (advanced an hour out) does not spuriously re-fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:02:20 +03:00
mixeme 94a8dcc62a P3.4: Add Queue group to settings view
Add Execution mode and Overlap policy widget.Select controls in
ui/settings_view.go under a new Queue section group, between the
Application and Storage sections. Both selects are primed from the
current config on open and their selected values are written back into
the config struct on save, passing through the existing UpdateSettings
validation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:57:51 +03:00
mixeme b3e699792a P3.3: Apply execution mode + overlap policy in dispatch
Rework RunDue/startRunLocked/executeRun so the ExecutionMode and
OverlapPolicy config knobs take effect:

- startRunLocked advances NextDue to the next occurrence instead of
  zeroing it, keeping the schedule marching during an in-flight run.
- RunDue scans all due jobs: parallel starts every due, non-running job;
  sequential defers a due job while any other job runs. When a job comes
  due again mid-run, skip drops it and queue marks it Pending; either way
  NextDue is advanced past the fired occurrence.
- executeRun re-runs a Pending job once the current run finishes.
- RunNow gains a sequential-mode guard refusing a manual run while
  another job is running.
- Add anyRunningLocked and advanceNextDueLocked helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:54:12 +03:00
mixeme d381a22034 P3.2: Move RunDue/RunNow/startRunLocked/executeRun into app/run.go
Extracts the four dispatch functions and their private helper
runningOutput into a dedicated file so operations.go stays focused on
CRUD and settings. No behaviour change; shared helpers (prependLog,
refreshNextRunLocked, etc.) remain in operations.go where other
operations already call them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:39:54 +03:00
mixeme 9b4e9ee311 P3.1: Add ExecutionMode/OverlapPolicy to Config; add Pending to JobRuntime
Introduces ExecutionMode (parallel/sequential) and OverlapPolicy
(skip/queue) types and constants in domain/config.go, wires defaults
(parallel/skip) into loadOrCreateConfig and the normalization pass, and
adds validation in validateConfig. Adds Pending bool to JobRuntime as
the flag P3.3 will use to re-run a queued overlap. Marks P3.1 done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:32:19 +03:00
mixeme e8421fc3ff Fix CRLF breakage in Linux build scripts
The Linux build host reads these scripts from a shared mount of the
Windows working tree, where autocrlf produces CRLF line endings. bash
then failed on `set -o pipefail` (trailing CR in the option name), and
the version parsed from version.go carried a trailing CR into the Docker
tag ("...:0.6.0\r": invalid reference format).

- Add .gitattributes forcing LF on *.sh so Windows checkouts stay LF.
- Strip CR from the version extraction in all three Linux build scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:31:05 +03:00
mixeme 6fcaa46f7a Bump version to 0.6.0; update changelog
Phase 2 (PySentry legacy removal) complete: removed legacy autostart code
for Windows and Linux, updated ignore files for JSON storage.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-22 22:24:52 +03:00
mixeme 2505181291 Mark P2.4 complete in task checklist 2026-06-22 22:24:08 +03:00
mixeme 8e67177c85 P2.4: Update .gitignore and .dockerignore for JSON storage
Drop pysentry.yaml references; add gosentry.json and jobs.json.
Keep *.yaml wildcard to ignore legacy files during import window.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-22 22:23:24 +03:00
mixeme 172cacb5fe P2.3: confirm legacy autostart tests are deleted
The legacy tests TestParseRegistryRunValue (Windows registry-based
autostart) and TestLinuxAutostartRemovesLegacyDesktopEntry (Linux systemd
cleanup) were already removed in P2.1 and P2.2. All remaining tests in
autostart_windows_test.go and autostart_linux_test.go test the current,
non-legacy implementation (Startup folder shortcuts and desktop files).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-22 22:14:47 +03:00
mixeme db9d5adb06 P2.2: remove PySentry legacy systemd + desktop autostart from Linux
Drop cleanupLegacySystemdAutostart, cleanupLegacyDesktopAutostart,
legacySystemdAutostartExists, legacyDesktopAutostartExists, and their
path helpers; remove calls from SetAutostart and AutostartStatus; drop
the os/exec import. Remove TestLinuxAutostartRemovesLegacyDesktopEntry
which referenced the deleted legacyAutostartDesktopPath function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:10:11 +03:00
mixeme a7faccef8a P2.1: remove PySentry legacy registry autostart from Windows
Drop legacyAutostartName, cleanupLegacyRegistryAutostart,
legacyRegistryAutostartExists, parseRegistryRunValue, and readShortcutTarget
from autostart_windows.go; remove the corresponding legacy registry checks
from SetAutostart and AutostartStatus; delete TestParseRegistryRunValue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:06:28 +03:00
mixeme 11b7012cf2 Bump version to 0.5.0; update changelog
Phase 1 complete: JSON storage + exit-code removal (P1.1-P1.6).
Settings and jobs now persist as gosentry.json / jobs.json with
one-time YAML import for existing installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:58:57 +03:00
mixeme 9af279e28f P1.6: update tests and docs for JSON storage + exit-code removal
- storage/store_test.go: switch TestJobsYAMLDoesNotPersistRuntimeNoise to
  json.Marshal (renamed to TestJobsJSONDoesNotPersistRuntimeNoise); add
  TestLoadOrCreateJobsMigratesFromLegacy to cover the jobs YAML import path
- app/operations_test.go: update newTempService paths to .json filenames
- docs/TESTS.md: reflect JSON round-trip, drop SuccessExitCodes tests, remove
  Exit Code Flexibility design-principle entry
- docs/PRE-RELEASE-TASKS.md: mark P1.6 complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:57:39 +03:00
mixeme fb149899e2 P1.5: drop SuccessExitCodes field and exit-code flexibility
Remove the SuccessExitCodes field from domain.Job and every layer that
read or wrote it: runner/exitcodes.go (deleted), runner.go runStateDetail
simplified to 0=OK / non-zero=Failed, logfile.go, format.go, operations.go,
store.go, job_dialog.go, and jobs_view.go. Tests updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:52:15 +03:00
mixeme d5418efe37 P1.4: one-time YAML import in storage
When gosentry.json / jobs.json are absent, read a pre-migration
gosentry.yaml / jobs.yaml via private yaml-tagged shadow structs and
return the data unsaved; the existing SaveConfig/SaveJobs in OpenStore
then rewrite it as JSON. Replaces the placeholder that pointed configPath
at the legacy YAML file and tried to json.Unmarshal it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:40:00 +03:00
mixeme 8df248a1b2 P1.3: rename storage files to gosentry.json / jobs.json
- ConfigFileName → "gosentry.json", JobsFileName → "jobs.json"
- Remove exported LegacyConfigFileName ("pysentry.yaml")
- Add unexported legacyYAMLConfigFileName / legacyYAMLJobsFileName for
  the upcoming one-time YAML import (P1.4)
- Update store.go fallback path and comments to describe YAML→JSON
  migration rather than the old PySentry→GoSentry rename
- Align store_test.go references and test comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:33:02 +03:00
mixeme 7ec5521309 Switch storage serialization from YAML to JSON (P1.2)
Replace writeYAML with writeJSON (json.MarshalIndent, 2-space indent,
trailing newline) and switch the config and jobs Unmarshal calls to
encoding/json. Mark P1.2 done in the pre-release task list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:24:28 +03:00
mixeme b7bbb41845 P1.1: swap yaml struct tags for json in domain types
Replace yaml:"…" tags with json:"…" on Job, Config, and JobsFile.
Add omitempty to bool fields in Config that previously lacked it.
Update comments to reference .json filenames.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:21:56 +03:00
mixeme 095ae557f1 Add pre-release milestone task list
Break the pre-release plan into phased tasks (P1-P8) with a per-task model
and thinking-depth recommendation and a completion checklist, following the
existing REFACTORING.md convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:14:27 +03:00
mixeme e853727e09 Add pre-release milestone plan
Document the planned work to take GoSentry from the v0.4.0 architectural
milestone to a pre-release build: JSON storage with one-time YAML import,
task-queue execution settings (parallel/sequential + overlap policy),
failure notifications, Command browse button, small/large icons, the Fyne
2.7 upgrade with tray-click-to-show, dropping the per-job success-exit-codes
feature, PySentry legacy cleanup, and a README split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:04:44 +03:00
mix 01fd572a89 Refactoring complete: v0.4.0 architectural milestone (#1)
## Summary

Completed Phase 5 refactoring and reached the target architecture.

**Architectural milestone achieved:**
- Service layer owns all state and is the sole writer
- UI is a thin Fyne view, all widget updates marshaled via `fyne.Do`
- Core engines are stateless and injectable
- Domain types are pure (no `yaml:"-"` fields)
- Full module builds and `go vet ./...` clean

## Changes

- Bump version: 0.3.6 → 0.4.0
- Update CHANGELOG with Phase 5 summary
- Add ROADMAP "Refactoring Follow-Ups" section

## Known follow-up work

1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag
2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines)

See ROADMAP.md for details.

---------

Co-authored-by: mixeme <mix.public@ya.ru>
Reviewed-on: #1
2026-06-22 08:05:10 +03:00
136 changed files with 12964 additions and 3927 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: Review the project as a whole against the agenda in docs/REVIEW.md
---
Perform a whole-project review of GoSentry.
Read [docs/REVIEW.md](../../docs/REVIEW.md) first — it is the agenda, and its
nine sections are the areas to cover. Read [docs/STANDARDS.md](../../docs/STANDARDS.md)
and [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the rules and
contracts the code is checked against.
$ARGUMENTS narrows the review when given — a package path, a file, or the name
of an agenda section. With no arguments, sweep the whole `src/` tree.
Rules for the report:
- Anything listed under "Intentional behavior" in STANDARDS.md is not a finding.
If you believe such an entry is now wrong, say so explicitly as a challenge to
the decision rather than reporting it as a bug.
- Verify before reporting. Read the surrounding code and, where cheap, confirm
the behavior with a test rather than reasoning about it alone.
- Group findings by agenda section, most severe first, each with the file and
line and what would actually go wrong.
- Report honestly that a section is clean rather than inventing something for it.
- Do not fix anything during the review. Report first; apply fixes only when
asked, following "What happens to the findings" in REVIEW.md.
+2 -3
View File
@@ -2,7 +2,6 @@
bin
dist
logs
gosentry.yaml
pysentry.yaml
jobs.yaml
gosentry.json
jobs.json
*.exe
+108
View File
@@ -0,0 +1,108 @@
name: Release
# Forgejo Actions workflow for Codeberg. It mirrors .github/workflows/release.yml
# and reuses the same scripts/ci-build-release.sh, so the actual build/package
# commands live in exactly one place. Codeberg has no Windows runners, but the
# Windows binary is cross-compiled with MinGW-w64 from the Linux job, so a single
# golang:1.22-bookworm container produces all three artifacts.
#
# Publishing needs a token that can write releases. Add a repository secret named
# RELEASE_TOKEN (a Codeberg access token with the "write:repository" scope) under
# Settings -> Actions -> Secrets. Without it the build still runs; only the
# upload step is skipped.
on:
release:
types: [published]
workflow_dispatch:
jobs:
release:
# Codeberg's hosted runners are tagged codeberg-tiny/small/medium (+ -lazy);
# there is no "docker" tag. The non-lazy runners cap at 2/5/10 min, which the
# three CGO cross-compiles blow past, so use the medium *-lazy* runner, which
# relaxes the wall-clock limit (it aims to finish within 24h).
runs-on: codeberg-medium-lazy
container:
image: golang:1.22-bookworm
steps:
- name: Provide Node.js for JS actions
# golang:1.22-bookworm ships no Node, and Codeberg's runner does not
# inject one, so JS actions (checkout, forgejo-release) fail with
# "node: not found". A run step executes through the container shell and
# needs no Node itself, so it can install Node before those actions run.
# (If a "node version" error ever appears, swap Debian's nodejs 18 for a
# NodeSource node 20 install.)
run: |
apt-get update
apt-get install -y --no-install-recommends nodejs
command -v node || ln -s "$(command -v nodejs)" /usr/local/bin/node
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
- name: Install cross toolchain
# Same package list as the repo Dockerfile / GitHub workflow: native gcc
# plus X11/GL headers, the aarch64 cross compiler with arm64 runtime
# libs, MinGW-w64 for the Windows GUI binary, and zip for packaging.
run: |
dpkg --add-architecture arm64
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
gcc \
libc6-dev \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
linux-libc-dev-arm64-cross \
gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 \
pkg-config \
libgl1-mesa-dev \
xorg-dev \
libgl1-mesa-dev:arm64 \
libx11-dev:arm64 \
libxcursor-dev:arm64 \
libxrandr-dev:arm64 \
libxinerama-dev:arm64 \
libxi-dev:arm64 \
libxxf86vm-dev:arm64 \
zip
rm -rf /var/lib/apt/lists/*
- name: Derive version
# On a release, strip the leading "v" so artifact names and the injected
# app version match the release tag (the release event still sets
# GITHUB_REF_TYPE=tag / GITHUB_REF_NAME=<tag>).
id: version
run: |
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Build and package
env:
VERSION: ${{ steps.version.outputs.value }}
run: |
chmod +x scripts/ci-build-release.sh
scripts/ci-build-release.sh
- name: Collect release files
# forgejo-release uploads every file in a single directory, so gather the
# archives into one flat folder.
run: |
mkdir -p dist/release
cp dist/linux/*.tar.gz dist/windows/*.zip dist/release/
- name: Attach assets to release
# Only runs for the release event; the release already exists, so this
# uploads the built archives to it. Skipped on workflow_dispatch.
if: ${{ github.event_name == 'release' }}
uses: https://code.forgejo.org/actions/forgejo-release@v2
with:
direction: upload
url: https://codeberg.org
repo: ${{ github.repository }}
tag: ${{ github.event.release.tag_name }}
release-dir: dist/release
token: ${{ secrets.RELEASE_TOKEN }}
override: true
+8
View File
@@ -0,0 +1,8 @@
# Keep shell scripts LF even on Windows checkouts so bash on Linux hosts
# doesn't choke on trailing CRs (e.g. "set: pipefail: invalid parameter name").
*.sh text eol=lf
# CI workflow YAML embeds shell in `run:` blocks that Linux runners execute with
# bash, so keep these LF for the same reason as the shell scripts above.
.github/workflows/*.yml text eol=lf
.forgejo/workflows/*.yml text eol=lf
+96
View File
@@ -0,0 +1,96 @@
name: Release
# Build the Linux (amd64/arm64) and Windows (amd64) binaries whenever a GitHub
# Release is published, then attach the packaged archives to that release.
#
# Everything runs inside golang:1.22-bookworm — the same base image as the
# repo Dockerfile — so the CGO/Fyne toolchain matches the local release builds.
# The Windows binary is cross-compiled with MinGW-w64 from the same Linux job,
# which is why no windows-latest runner is needed.
on:
release:
types: [published]
# Allow a manual run (from the Actions tab) to smoke-test the build without
# publishing a release. Manual runs build the artifacts but upload nothing.
workflow_dispatch:
permissions:
contents: write # required to create the release and upload assets
jobs:
release:
runs-on: ubuntu-latest
container:
image: golang:1.22-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install cross toolchain
# Mirrors the package list in the repo Dockerfile: native gcc + X11/GL
# headers for amd64, the aarch64 cross compiler with arm64 runtime libs,
# and the MinGW-w64 toolchain for the Windows GUI binary. zip packages
# the Windows archive.
run: |
dpkg --add-architecture arm64
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
gcc \
libc6-dev \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
linux-libc-dev-arm64-cross \
gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 \
pkg-config \
libgl1-mesa-dev \
xorg-dev \
libgl1-mesa-dev:arm64 \
libx11-dev:arm64 \
libxcursor-dev:arm64 \
libxrandr-dev:arm64 \
libxinerama-dev:arm64 \
libxi-dev:arm64 \
libxxf86vm-dev:arm64 \
zip
rm -rf /var/lib/apt/lists/*
- name: Derive version
# For a release, use the tag without its leading "v" so the artifact
# names and the injected app version match the release (the release
# event still sets GITHUB_REF_TYPE=tag / GITHUB_REF_NAME=<tag>).
# Otherwise fall back to the version in source (handled by the build
# script).
id: version
run: |
ref="${GITHUB_REF_NAME:-}"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "value=${ref#v}" >> "$GITHUB_OUTPUT"
fi
- name: Build and package
env:
VERSION: ${{ steps.version.outputs.value }}
run: |
chmod +x scripts/ci-build-release.sh
scripts/ci-build-release.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: gosentry-release
path: |
dist/linux/*.tar.gz
dist/windows/*.zip
- name: Attach assets to release
# Only runs for the release event; the release already exists, so this
# just uploads the built archives to it. Skipped on workflow_dispatch.
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
dist/linux/*.tar.gz
dist/windows/*.zip
+9 -3
View File
@@ -1,6 +1,9 @@
# Build outputs
dist/
# Persistent Go build cache for Docker-based release builds.
.gocache/
# Generated Windows resource compiled from packaging/windows/gosentry.rc.
cmd/gosentry/*.syso
@@ -9,15 +12,18 @@ cmd/gosentry/*.syso
*.test
# Runtime files created next to the executable during local runs.
gosentry.yaml
pysentry.yaml
jobs.yaml
gosentry.json
jobs.json
logs/
# Go workspace/cache files that should stay local if a developer creates them.
go.work
go.work.sum
# Claude Code per-developer settings. The shared command in .claude/commands is
# tracked; locally granted tool permissions are not.
.claude/settings.local.json
# GoodSync metadata. This is intentionally kept because the directory is local
# to the user's file synchronization setup.
_gsdata_/
+59
View File
@@ -0,0 +1,59 @@
# GoSentry — instructions for Claude Code
Cross-platform desktop scheduler (Go + Fyne GUI). Single process: GUI,
application service, scheduler, storage, and command runner in one binary.
## Read before changing code
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
the list of intentional behavior. Do not "fix" anything listed there as
intentional; if a change contradicts it, update the document in the same commit.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts and event flow.
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
## Reviewing the project
When the user asks for a review of the project (rather than of a specific
diff), follow [docs/REVIEW.md](docs/REVIEW.md) — it is the agenda, and the
`/review-project` command runs the same thing. Do not improvise a checklist.
## Key rules (full list in STANDARDS.md)
- `src/app.Service` is the sole owner of job and runtime state; the UI reads it
through typed events, never through shared mutable state.
- User-facing errors go to `dialog.ShowError` or a History event — never a silent
`return`.
- Pure helpers get a unit test in the same package; fixes of severity ≥ medium get
a regression test.
- UI view constructors accept an injected `*app.Service`; `app.Open()` is called
only from `run.go`.
- Off-main-thread widget updates must go through `fyne.Do` (Fyne v2.7.4).
## Build and test
CGO is required — the Fyne GUI links native libraries. On Windows the toolchain
is MSYS2 UCRT64; the default shell environment here has CGO off, so set it
explicitly:
```powershell
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path; $env:CGO_ENABLED = '1'
```
Then:
```powershell
scripts\test.bat
```
which runs `go vet ./...` and `go test -race ./...`. Release binaries come from
`scripts\build-windows.bat` / `scripts/build-linux.sh` — see
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
## Repository conventions
- Commit directly to `main`; do not create feature branches.
- Notable changes get a [docs/CHANGELOG.md](docs/CHANGELOG.md) entry under the
current version.
- The window/taskbar icon comes from the `gosentry.ico` PE resource — regenerate
it from the PNGs whenever an icon changes, not just the embedded asset.
+198 -305
View File
@@ -1,301 +1,142 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo/gosentry-logo-dark.svg">
<img src="assets/logo/gosentry-logo.svg" alt="GoSentry" width="420">
</picture>
</p>
# GoSentry
GoSentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
GoSentry is a cross-platform desktop scheduler. It provides a native GUI for
creating, grouping, pausing, running, and monitoring scheduled shell commands.
GoSentry is being designed and implemented with assistance from OpenAI Codex.
## Screenshots
Project notes:
- [Changelog](docs/CHANGELOG.md)
- [Roadmap](docs/ROADMAP.md)
- [Architecture](docs/ARCHITECTURE.md)
<table>
<tr>
<td align="center"><img src="images/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
<td align="center"><img src="images/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
</tr>
</table>
## Features
- Native desktop GUI built with [Fyne](https://fyne.io/).
- Job storage in one clean YAML file.
- App settings in a separate YAML file.
- `@every` schedules and standard 5-field cron expressions.
- Job definitions stored in a clean, hand-editable `jobs.json`.
- `@every` intervals and standard 5-field cron expressions.
- Manual and scheduled command runs.
- Per-run `.log` files with stdout/stderr.
- Parallel or sequential execution mode; overlap policy (skip or queue) set globally or per job.
- Run timeout, off by default, set globally or per job.
- Per-run `.log` files with stdout/stderr capture.
- Log cleanup by maximum file count and maximum age.
- Global pause/resume for all job execution.
- Windows tray support.
- Version shown in the window title, Settings, and build artifact names.
- Global pause/resume for scheduled job execution (manual runs remain available).
- Desktop notifications on job failure.
- Windows tray icon: left-click to show the window, right-click for the menu.
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
- Detailed or compact job list, and a default or branded theme; both are remembered.
## Requirements
## Platforms
Common:
GoSentry is built and tested on **Windows** and **Linux**:
- [Go](https://go.dev/) 1.22 or newer.
| Platform | Status | Notes |
|----------|--------|-------|
| Windows | Supported | Tray icon, autostart shortcut (`.lnk`), desktop integration. |
| Linux | Supported | Autostart via XDG desktop entry; desktop integration on X11/Wayland. |
| macOS | Not supported | The Fyne GUI may build, but autostart and desktop integration are not implemented. |
Windows:
## Documentation
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
Install these dependencies on Windows:
```powershell
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
# The default installer path is C:\Program Files\Go.
go version
# 2. Install MSYS2 from https://www.msys2.org/.
# Use the default installation path so UCRT64 tools are placed under
# C:\msys64\ucrt64\bin.
# 3. Open "MSYS2 UCRT64" from the Start menu and install GCC plus windres.
pacman -Syu
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-binutils
# 4. In PowerShell, check that the compiler is available where the build script
# expects it. build-windows.bat prepends this directory automatically.
Test-Path C:\msys64\ucrt64\bin\gcc.exe
Test-Path C:\msys64\ucrt64\bin\windres.exe
```
Linux:
- A C compiler.
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11 development packages.
On Debian/Ubuntu, the Linux dependencies are typically:
```bash
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
# development packages provide the native desktop headers used by Fyne.
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
```
## Build
Windows:
```powershell
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
# to the repository root first, so double-clicking it from Explorer works. It
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
# when windres is available, and uses the Windows GUI subsystem so no console
# window opens at startup.
.\scripts\build-windows.bat
```
The Windows build is created as a GUI application, so it does not open a terminal window.
The binary is written to:
```text
# GUI executable produced by scripts\build-windows.bat.
dist\windows\gosentry-0.3.0-windows-amd64.exe
```
Linux:
```bash
# Make the helper executable once, then build a linux/amd64 Fyne binary.
chmod +x ./scripts/build-linux.sh
./scripts/build-linux.sh
```
The binary is written to:
```text
# Linux executable produced by scripts/build-linux.sh.
dist/linux/gosentry-0.3.0-linux-amd64
```
Linux using Docker:
```bash
# Builds the Linux binary inside Docker using the versioned image tag
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
# where the native Linux/Fyne packages are not installed locally.
chmod +x ./scripts/build-linux-docker.sh
./scripts/build-linux-docker.sh
```
The binary is copied to:
```text
# Linux executable copied out of the Docker build image.
dist\linux\gosentry-0.3.0-linux-amd64
```
Release build from Linux:
```bash
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
# from one Linux/Docker workflow. The Dockerfile contains the builder
# environment; the build commands live in this script. Docker runs the build
# with the current user's UID/GID so dist/ files are not owned by root.
chmod +x ./scripts/build-release-linux.sh
./scripts/build-release-linux.sh
```
Non-interactive release builds can pass target names:
```bash
# Build only Linux arm64 and Windows amd64 artifacts.
./scripts/build-release-linux.sh linux-arm64 windows-amd64
```
The binaries are copied to:
```text
# Linux artifact.
dist/linux/gosentry-0.3.0-linux-amd64
# Linux arm64 artifact.
dist/linux/gosentry-0.3.0-linux-arm64
# Windows artifact cross-compiled from Linux.
dist/windows/gosentry-0.3.0-windows-amd64.exe
```
## Run From Source
Windows:
```powershell
# Fyne requires CGO on Windows. MSYS2 UCRT64 provides the C compiler and native
# libraries used by the desktop backend.
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
$env:CGO_ENABLED = '1'
# go run starts the app from source. Use scripts\build-windows.bat when you need
# a standalone .exe without a console window.
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/gosentry
```
Linux:
```bash
# CGO must stay enabled because the Fyne GUI links against native Linux desktop
# libraries.
CGO_ENABLED=1 go run ./cmd/gosentry
```
## Troubleshooting
### Windows, VirtualBox, RDP, And OpenGL
GoSentry uses [Fyne](https://fyne.io/), and Fyne uses GLFW/OpenGL to create the
desktop window. In a Windows virtual machine, especially when the session is
opened through RDP inside VirtualBox, the available video driver can fail OpenGL
initialization.
Typical error:
```text
Fyne error: window creation error
Cause: APIUnavailable: WGL: The driver does not appear to support OpenGL
At: fyne.io/fyne/v2@v2.5.3/internal/driver/glfw/driver.go:149
```
Known workaround:
1. Download a Windows Mesa build from
[mesa-dist-win](https://github.com/pal1000/mesa-dist-win/releases). For a
regular Windows x64 GoSentry build, use the archive named like
`mesa3d-<version>-release-mingw.7z`, for example
`mesa3d-26.1.1-release-mingw.7z`. This matches the MSYS2 GCC toolchain used
to build GoSentry. The `devel`, `debug-info`, `tests`, and checksum files
are not needed for this workaround.
2. Open the downloaded archive and use the `x64` build from it.
3. Copy the Mesa OpenGL DLL files from `x64` into the same directory as the
GoSentry `.exe`, for example:
```text
dist\windows\
gosentry-0.3.0-windows-amd64.exe
opengl32.dll
...
```
This makes Windows load Mesa's software OpenGL implementation next to the
application binary, which lets the Fyne window start even when the VirtualBox/RDP
driver does not provide usable OpenGL.
- [Changelog](docs/CHANGELOG.md) — record of notable changes by version
- [Roadmap](docs/ROADMAP.md) — planned work larger than a single bug fix
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
- [Standards](docs/STANDARDS.md) — quality rules and intentional behavior
- [Review](docs/REVIEW.md) — what a whole-project review looks at
- [Development](docs/DEVELOPMENT.md) — build instructions, project layout, dependencies
- [Tests](docs/TESTS.md) — test suite layout and how to run it
- [Performance](docs/PERFORMANCE.md) — measured performance findings
## Storage
GoSentry creates its runtime files next to the executable by default.
GoSentry stores its files next to the executable by default, making it a
portable application: moving the program folder also moves its configuration.
`gosentry.yaml` stores application settings:
`gosentry.json` stores application settings:
```yaml
# Directory containing jobs.yaml. "." means "the folder where the GoSentry
# executable lives"; an absolute path can be used when jobs should live elsewhere.
jobs_dir: .
# Directory for per-run command output logs. Relative paths are resolved against
# the program folder, just like jobs_dir.
logs_dir: logs
# Keep at most this many .log files after cleanup. Newest logs are preserved.
max_log_files: 100
# Delete .log files older than this many days during cleanup.
max_log_age_days: 30
# Start GoSentry automatically when the current desktop user signs in.
start_on_login: false
# Closing the window hides it to the tray instead of stopping the scheduler.
keep_running_in_tray: true
# Reserved for desktop failure notifications; the setting is stored now so the
# UI and config format do not need to change when notifications are wired fully.
notify_on_failure: true
```json
{
"jobs_file": "jobs.json",
"logs_dir": "logs",
"max_log_files": 100,
"max_log_age_days": 30,
"keep_running_in_tray": true,
"notify_on_failure": true,
"execution_mode": "parallel",
"overlap_policy": "skip",
"default_timeout_seconds": 0,
"theme": "default",
"job_list_view": "detailed"
}
```
`jobs.yaml` stores only job definitions:
That is the file GoSentry writes on first run. `default_timeout_seconds` is the
run timeout applied to jobs that do not set their own; `0` means no timeout, and
it is written out even though it is zero, because a missing value and a
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
`theme` is `default` or `gosentry` (the branded teal/amber look), and
`job_list_view` is `detailed` or `compact` — both are remembered from the
choices made in the app. Keys left at their off value (`start_on_login`,
`paused`) are omitted until they are turned on.
```yaml
jobs:
# A harmless sample job created on first run so the scheduler can be tested
# immediately. Runtime fields such as last run time, next run time, and command
# output are intentionally not stored here; they are displayed in the GUI and
# written to separate log files.
- id: 1
# Human-readable name shown in the jobs list and used in log file names.
name: Hello scheduler
`jobs.json` stores job definitions:
# Optional grouping label. Omit it or leave it empty to put the job under
# the "No folder" filter.
folder: Examples
# Either @every with a Go duration, or a standard five-field cron expression.
schedule: '@every 1m'
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
command: echo GoSentry test job: scheduler is alive
# Disabled jobs remain in jobs.yaml but are skipped by the scheduler.
enabled: true
```json
{
"jobs": [
{
"id": 1,
"name": "Hello scheduler",
"folder": "Examples",
"schedule": "@every 1m",
"command": "echo GoSentry test job: scheduler is alive",
"enabled": true
}
]
}
```
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
`jobs_file` is the file GoSentry reads job definitions from, file name included,
so the file can be named anything. The default `"jobs.json"` is relative and
resolves to the executable's folder. An absolute path can be used when jobs
should live elsewhere, such as a shared network drive.
A `gosentry.json` from an earlier version that carries `jobs_dir` instead keeps
working: the directory is combined with `jobs.json` on load, and the file is
rewritten with `jobs_file`.
`logs_dir` is relative to the program folder when it does not start with a
drive letter or `/`.
Command output is written to separate files under `logs_dir`. File names
include the run timestamp and job name:
```text
# Format: YYYYMMDD-HHMMSS_<sanitized job name>.log
20260614-224306_Hello_scheduler.log
```
## Schedules
Fast interval schedules:
Interval schedules using Go duration syntax:
```text
# Go duration syntax after @every; useful for tests and simple intervals.
@every 10s
@every 5m
@every 1h30m
```
Standard 5-field cron schedules:
Standard 5-field cron expressions:
```text
# Standard five-field cron: minute hour day-of-month month day-of-week.
*/5 * * * * every five minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30
@@ -304,81 +145,133 @@ Standard 5-field cron schedules:
## Using The App
1. Start GoSentry.
2. Use `New job` to create a command.
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
4. Use `Run now` for a manual test run.
5. Use `Pause` to disable one job.
6. Use `Pause all` as a global stop switch.
7. Open `History` to see whether a run was `Manual`, `Schedule`, or `UI`.
8. Open `Settings` to change `jobs_dir`, `logs_dir`, and log cleanup limits. Use `Browse` to choose directories.
2. Use **New job** to create a scheduled command.
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
4. Use **Run now** for a one-off manual run without waiting for the schedule.
5. Use **Pause** on a single job to suspend it without deleting it.
6. Use **Pause all** as a global stop switch for all scheduled runs.
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
Changing `jobs_dir` saves the current job list to the new directory.
The **Jobs file** row picks the file itself: **Browse** lists `.json` files, and
a path can also be typed to name a file that does not exist yet. What Save does
depends on whether that file is already there:
The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path.
Autostart entries add `--start-in-tray`, so scheduled jobs begin running after sign-in without opening the main window.
- **The file exists** — its jobs are loaded and replace the current list, so
selecting a jobs file switches to it (another machine's file, a shared one on
a network drive). History records how many jobs were loaded and from where.
- **The file does not exist** — the current jobs are written to it, which is how
the jobs file is renamed or moved somewhere else.
Switching to a different jobs file is refused while a job is running, because
loading a new list discards the run state of the old one.
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
it enabled writes an autostart entry using the current executable path.
Autostart entries include `--start-in-tray` so scheduled jobs run after sign-in
without opening the main window.
## Queue Settings
Three settings in the **Queue** group of the Settings tab control how
simultaneous, overlapping, and over-long runs are handled.
**Execution mode** — applies when multiple jobs become due at the same tick:
| Value | Behaviour |
|-------|-----------|
| `parallel` (default) | All due jobs start at the same time. |
| `sequential` | Due jobs are started one after another, in the order they appear in the list. |
**Default overlap policy** — applies when a job's next scheduled run fires while
its previous run is still active:
| Value | Behaviour |
|-------|-----------|
| `skip` (default) | The new run is discarded; the running instance continues. |
| `queue` | The new run is held and starts immediately after the current run finishes. |
**Default timeout (s)** — how long a run may take before it is killed. `0` (the
default) means no limit.
The last two are defaults: a job's own dialog has an **Overlap policy** and a
**Timeout (s)** field that override them. A job that overrides nothing follows
whatever the Settings tab says, so changing a default moves every such job with
it. In `jobs.json` an override is an `overlap_policy` or `timeout_seconds` key
on the job; absent means inherit. A `"timeout_seconds": 0` on a job is an
override too — it means that job has no timeout even when the global default
sets one.
## Notifications
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
notification whenever a scheduled or manual run exits with a non-zero exit code.
The notification shows the job name and the exit code.
## Autostart
GoSentry is a user desktop application, not a system daemon, so autostart should be configured per user.
GoSentry is a user desktop application, not a system daemon, so autostart is
configured per user.
Linux:
```ini
# GoSentry writes an XDG Autostart desktop entry when Start on login is enabled.
# This is better for a GUI/tray application than a systemd user service because
# the desktop environment starts it inside the graphical user session.
# Saving the setting also removes the old ~/.config/systemd/user/pysentry.service
# unit if it was created by an earlier GoSentry build.
~/.config/autostart/gosentry.desktop
[Desktop Entry]
Type=Application
Name=GoSentry
Exec=/opt/gosentry/gosentry-0.3.0-linux-amd64 --start-in-tray
Exec=/opt/gosentry/gosentry-<version>-linux-amd64 --start-in-tray
Terminal=false
```
Windows:
```text
# GoSentry writes a shortcut to the current user's Startup folder when Start on
# login is enabled. A .lnk stores the executable path as a structured TargetPath,
# and stores --start-in-tray as Arguments, so paths with spaces do not need
# fragile command-line quoting. Saving settings rewrites the shortcut and removes
# old HKCU Run entries from earlier builds.
# GoSentry writes a shortcut to the current user's Startup folder.
# A .lnk stores the executable path as TargetPath and --start-in-tray as
# Arguments, so paths with spaces do not need fragile command-line quoting.
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk
```
## Project Layout
## Troubleshooting
- `cmd/gosentry` starts the desktop app.
- `src/gui` contains the GUI.
- `src/core` contains YAML storage, command execution, scheduling, and log cleanup.
- `assets` contains app icons that are embedded into the application binary.
- `scripts` contains build helpers.
- `docs` contains architecture notes, the changelog, and the roadmap.
### Windows, VirtualBox, RDP, And OpenGL
Build outputs are written to `dist/`. The old local `bin/` directory is not used.
GoSentry uses [Fyne](https://fyne.io/), and Fyne uses GLFW/OpenGL to create the
desktop window. In a Windows virtual machine, especially when accessed through
RDP inside VirtualBox, the available video driver can fail OpenGL initialization.
## Dependencies
Typical error:
GoSentry keeps the direct dependency list intentionally small:
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
- `github.com/robfig/cron/v3` for cron schedule parsing.
- [`go.yaml.in/yaml/v4`](https://github.com/yaml/go-yaml) for YAML settings and jobs.
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
Source repositories for mirroring:
- Go toolchain: https://go.googlesource.com/go
- Fyne: https://github.com/fyne-io/fyne
- robfig/cron: https://github.com/robfig/cron
- yaml/go-yaml: https://github.com/yaml/go-yaml
To list every direct and indirect Go module used by the current checkout:
```bash
go list -m all
```text
Fyne error: window creation error
Cause: APIUnavailable: WGL: The driver does not appear to support OpenGL
```
Known workaround:
1. Download a Windows Mesa build from
[mesa-dist-win](https://github.com/pal1000/mesa-dist-win/releases). Use the
archive named like `mesa3d-<version>-release-mingw.7z` — this matches the
MSYS2 GCC toolchain used to build GoSentry. The `devel`, `debug-info`,
`tests`, and checksum files are not needed.
2. Open the archive and use the `x64` build.
3. Copy the Mesa OpenGL DLL files from `x64` into the same directory as the
GoSentry `.exe`:
```text
dist\windows\
gosentry-<version>-windows-amd64.exe
opengl32.dll
...
```
Mesa's software OpenGL implementation lets the Fyne window start even when the
VirtualBox/RDP driver does not provide usable OpenGL.
## Development assistance
Parts of this project were developed with assistance from [Cursor](https://cursor.com/) AI (Composer agent)
and [Claude Code](https://claude.com/claude-code).
+76 -14
View File
@@ -6,25 +6,87 @@ import (
"fyne.io/fyne/v2"
)
// The application icon is embedded into the binary instead of being loaded from
// an assets directory at runtime. That keeps the Windows/Linux distribution to a
// single executable and avoids the common failure mode where the app starts with
// a generic icon because a sidecar PNG was not copied with the binary.
// Icons are embedded into the binary instead of being loaded from an assets
// directory at runtime. That keeps the Windows/Linux distribution to a single
// executable and avoids the common failure mode where the app starts with a
// generic icon because a sidecar PNG was not copied with the binary. The blank
// "embed" import enables the //go:embed directives below.
//
// The blank import enables the compiler directive below; no runtime package
// initialization from embed is required.
// # Cross-platform icon strategy
//
//go:embed gosentry-icon-big.png
var iconBytes []byte
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
// image, which the OS then scales to every size it needs — titlebar (~16px),
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
// downscaling the 1254px gosentry-icon-big.png to 16px is muddy, and upscaling
// the 16px icon to 32px is blurry. The fix is to feed each surface a
// size-appropriate source — which differs per platform because each platform
// exposes different icon channels.
//
// Source assets (all have a transparent boundary; note that a *binary* white-key
// leaves the anti-aliased edge fully opaque as a light halo that reads as a
// border on a dark taskbar/tray, so the background is removed with feathered
// color-to-alpha instead):
// - gosentry-icon-large.png detailed large artwork (teal rounded-tile emblem)
// - gosentry-icon-small.png hand-tuned for legibility at 16px
// - gosentry.ico multi-size 16/32/48/256 (16 = the hand-tuned PNG,
// the rest downscaled from large). Embedded into the PE
// binary by windres (see scripts/build-windows.bat),
// NOT via Go embed.
// - gosentry-icon-small.ico single 16px frame, for the Windows tray
//
// Windows:
// - Window titlebar + taskbar: the multi-size gosentry.ico, embedded by the .rc
// under the resource name GLFW_ICON (packaging/windows/gosentry.rc). GLFW uses
// it as the window's default icon and selects the right frame per size — the
// hand-tuned 16 for the titlebar, a larger frame for the taskbar. For this to
// work, src/ui/run.go must NOT call a.SetIcon on Windows: a single SetIcon
// resource overrides GLFW_ICON and would be scaled to both sizes.
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
// (a multi-size .ico made the tray pick and downscale a larger frame).
//
// Linux / other non-Windows (no PE icon resource exists):
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
// _NET_WM_ICON, which the window manager renders ~16px in the titlebar, so the
// hand-tuned 16x16 keeps it crisp.
// - Dock/launcher: the larger icon comes from the .desktop entry's Icon=, written
// by InstallDesktopIcon (src/platform/desktop) from the big artwork.
// - Tray: SetSystemTrayIcon(Icon()). StatusNotifierItem renders 22-48px and takes
// a PNG, so the big artwork scales down cleanly (the 16x16 would look tiny).
//go:embed gosentry-icon-small.png
var iconSmallBytes []byte
//go:embed gosentry-icon-large.png
var iconLargeBytes []byte
//go:embed gosentry-icon-small.ico
var iconSmallICOBytes []byte
// IconSmall returns the hand-tuned 16x16 PNG. It is the Linux window-titlebar
// icon (via a.SetIcon -> _NET_WM_ICON, which the WM renders at ~16px). On Windows
// the titlebar comes from gosentry.ico instead; see the package strategy above.
func IconSmall() fyne.Resource {
return fyne.NewStaticResource("gosentry-icon-small.png", iconSmallBytes)
}
// IconSmallICO returns a single-frame 16x16 Windows .ico of the hand-tuned small
// icon, used for the Windows system tray. The notification area is ICO-native, and
// pinning a single 16x16 frame keeps the hand-tuned glyph crisp at tray size — a
// multi-size .ico lets the tray pick and downscale a larger, muddier frame.
func IconSmallICO() fyne.Resource {
return fyne.NewStaticResource("gosentry-icon-small.ico", iconSmallICOBytes)
}
// Icon returns the large artwork PNG. It is the Linux tray icon (StatusNotifierItem
// renders 22-48px) and the source for the Linux .desktop dock icon via IconBytes.
// The Windows window/taskbar icon comes from gosentry.ico, not this resource.
func Icon() fyne.Resource {
// Fyne accepts resources from memory, so the same embedded PNG can be used
// for the window icon and tray icon. The Windows Explorer icon is still added
// by the build script through the .ico resource, because Explorer reads PE
// resources rather than Fyne runtime state.
return fyne.NewStaticResource("gosentry-icon-big.png", iconBytes)
return fyne.NewStaticResource("gosentry-icon-large.png", iconLargeBytes)
}
// IconBytes is the large artwork as raw PNG bytes for InstallDesktopIcon, which
// writes the Linux .desktop launcher/dock icon.
func IconBytes() []byte {
return append([]byte(nil), iconBytes...)
return append([]byte(nil), iconLargeBytes...)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoSentry — логотип v3</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Space+Grotesk:wght@500;600;700&family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--petrol:#0A4A58; --petrol-900:#04262E; --amber:#F7A80C;
--ink:#04252C; --paper:#F1F5F5; --line-l:rgba(4,37,44,.12);
}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);
font-family:"Inter",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1080px;margin:0 auto;padding:0 26px}
header{background:radial-gradient(120% 140% at 80% 0%,var(--petrol),var(--petrol-900));color:#fff;padding:50px 0 40px}
header .eyebrow{font-family:"Space Grotesk";font-weight:600;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:var(--amber);margin:0 0 12px}
header h1{font-family:"Space Grotesk";font-weight:700;font-size:clamp(24px,4vw,34px);margin:0 0 8px;letter-spacing:-.02em}
header p{margin:0;color:rgba(255,255,255,.72);max-width:62ch;font-size:15px}
section{border-bottom:1px solid var(--line-l);padding:40px 0}
.chead{display:flex;align-items:baseline;gap:14px;margin-bottom:6px;flex-wrap:wrap}
.num{font-family:"Space Grotesk";font-weight:700;color:var(--amber);font-size:14px}
.ctitle{font-family:"Space Grotesk";font-weight:700;font-size:19px;margin:0;letter-spacing:-.01em}
.cnote{font-size:14px;color:#4b666e;margin:0 0 22px;max-width:66ch}
/* wordmark engine */
.wm{font-weight:600;letter-spacing:-.03em;line-height:1;white-space:nowrap;display:inline-flex;align-items:baseline}
.wm .am{color:var(--amber)} .wm .pe{color:var(--petrol)}
.deep .wm .pe{color:#fff}
.dial{width:.68em;height:.68em;display:inline-block;transform:translateY(.02em);margin:0 .015em}
.f-space{font-family:"Space Grotesk"} .f-sora{font-family:"Sora"} .f-bric{font-family:"Bricolage Grotesque"}
.hero-stage{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.panel{border-radius:16px;min-height:170px;display:flex;align-items:center;justify-content:center;padding:30px;border:1px solid var(--line-l);position:relative}
.panel.light{background:#fff}
.panel.deep{background:var(--petrol-900);border-color:transparent}
.tag{position:absolute;top:12px;left:14px;font-family:"Space Grotesk";font-weight:600;font-size:10px;letter-spacing:.14em;color:rgba(4,37,44,.22);text-transform:uppercase}
.deep .tag{color:rgba(255,255,255,.3)}
.hero .wm{font-size:clamp(46px,11vw,82px)}
.row{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.cell{background:#fff;border:1px solid var(--line-l);border-radius:14px;padding:26px 18px;text-align:center}
.cell .lbl{font-family:"Space Grotesk";font-weight:600;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:#5E7580;margin:0 0 16px}
.cell .wm{font-size:38px}
.rec{display:inline-block;font-family:"Space Grotesk";font-weight:700;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--amber);border:1px solid var(--amber);border-radius:5px;padding:2px 6px;margin-left:8px;vertical-align:1px}
.faces{display:grid;grid-template-columns:1fr;gap:12px}
.frow{background:#fff;border:1px solid var(--line-l);border-radius:14px;padding:20px 26px;display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap}
.frow .fn{font-family:"Space Grotesk";font-weight:600;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#5E7580;min-width:150px}
.frow .wm{font-size:clamp(34px,7vw,50px)}
.foot{padding:30px 0 60px;color:#5E7580;font-family:"Space Grotesk";font-weight:500;font-size:12.5px}
:focus-visible{outline:3px solid var(--amber);outline-offset:3px}
@media (max-width:760px){.hero-stage{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<div class="wrap">
<p class="eyebrow">GoSentry / logotype · v3</p>
<h1>Two-tone + деталь: «o» как циферблат</h1>
<p>В слове уже есть готовый кружок — буква «o» в «Go». Делаем из неё маленький циферблат: тема расписания и «дозора» встроена прямо в надпись, а не приклеена сбоку иконкой.</p>
</div>
</header>
<!-- HERO: recommended -->
<section class="hero"><div class="wrap">
<div class="chead"><span class="num"></span><h3 class="ctitle">Рекомендую: Space Grotesk + стрелки</h3></div>
<p class="cnote">Янтарные «G» и циферблат-«o», петрол «Sentry». Циферблат со стрелками сразу читается как часы и держит характер даже мелко.</p>
<div class="hero-stage">
<div class="panel light"><span class="tag">light</span>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
<div class="panel deep"><span class="tag">dark</span>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
</div>
</div></section>
<!-- dial intensity -->
<section><div class="wrap">
<div class="chead"><span class="num">01</span><h3 class="ctitle">Насколько «часы» показывать</h3></div>
<p class="cnote">Три уровня детали в «o». Слева — обычная буква (деталь почти незаметна), в центре — одна засечка на 12 (тонкий намёк на дозор/время), справа — полноценные стрелки.</p>
<div class="row">
<div class="cell"><p class="lbl">plain — просто «o»</p>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/></svg><span class="pe">Sentry</span></span>
</div>
<div class="cell"><p class="lbl">tick — засечка на 12</p>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 19 L50 32" stroke="#F7A80C" stroke-width="11" stroke-linecap="round"/><circle cx="50" cy="50" r="5" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
<div class="cell"><p class="lbl">hands — стрелки <span class="rec">рек.</span></p>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
</div>
</div></section>
<!-- face choice in context -->
<section><div class="wrap">
<div class="chead"><span class="num">02</span><h3 class="ctitle">Шрифт — теперь разница видна</h3></div>
<p class="cnote">Одна и та же деталь, три гарнитуры крупно. Смотри на «G», «S», «t», «y» — там весь характер. Space Grotesk — техничный и приметный; Sora — гладкий, нейтральный; Bricolage — самый выразительный, «человечный».</p>
<div class="faces">
<div class="frow"><span class="fn">Space Grotesk ★</span>
<span class="wm f-space"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
<div class="frow"><span class="fn">Sora</span>
<span class="wm f-sora"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
<div class="frow"><span class="fn">Bricolage</span>
<span class="wm f-bric"><span class="am">G</span><svg class="dial" viewBox="0 0 100 100"><circle cx="50" cy="50" r="36" fill="none" stroke="#F7A80C" stroke-width="13"/><path d="M50 50 L50 28" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><path d="M50 50 L67 58" stroke="#F7A80C" stroke-width="9" stroke-linecap="round"/><circle cx="50" cy="50" r="6" fill="#F7A80C"/></svg><span class="pe">Sentry</span></span>
</div>
</div>
</div></section>
<div class="foot"><div class="wrap">Выбери уровень детали (plain / tick / hands) + шрифт — переведу «o»-циферблат и весь логотип в кривые и отдам чистый SVG (light/dark) + PNG.</div></div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+41
View File
@@ -0,0 +1,41 @@
# GoSentry logo
Recommended wordmark variant from [`../gosentry-logo.html`](../gosentry-logo.html):
**Space Grotesk SemiBold** with an amber **G**, a **clock-dial "o"** (ring + hands),
and a petrol **"Sentry"**. The dial sits exactly in the `o` slot, so the schedule /
"watch" theme lives inside the letters instead of a bolt-on icon.
## Colors
| token | hex | use |
|--------|-----------|-----------------------------|
| amber | `#F7A80C` | `G`, dial ring + hands |
| petrol | `#0A4A58` | `Sentry` (light background) |
| white | `#FFFFFF` | `Sentry` (dark background) |
## Files
Vector (self-contained — glyphs are outlined to paths, no font required):
- `gosentry-logo.svg` — transparent, petrol `Sentry` (for light backgrounds)
- `gosentry-logo-dark.svg` — transparent, white `Sentry` (for dark backgrounds)
- `gosentry-logo-mono.svg` — single-color petrol
Raster (transparent PNG, aspect ≈ 4326×1034 ≈ 4.18:1):
- `gosentry-logo-{256,512,1024,2048}.png` — petrol `Sentry`
- `gosentry-logo-dark-{256,512,1024,2048}.png` — white `Sentry`
## Regenerating
Requires `fonttools` and `matplotlib`, plus the Space Grotesk variable font
(SIL OFL) instanced to weight 600 as `SpaceGrotesk-600.ttf`:
```sh
python gen_logo.py # writes SVGs into ./out
python raster.py # writes PNGs into ./out
```
`gen_logo.py` (SVG) and `raster.py` (PNG) share the same layout + dial geometry,
so both outputs stay identical. Space Grotesk is licensed under the SIL Open Font
License; outlining its glyphs into a logo is permitted.
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Generate GoSentry wordmark logo assets (recommended variant:
Space Grotesk SemiBold + amber 'G' + clock-dial 'o' with hands + 'Sentry')."""
import os
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.pens.boundsPen import BoundsPen
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
AMBER = "#F7A80C"
PETROL = "#0A4A58"
WHITE = "#FFFFFF"
LS = -30 # letter-spacing -0.03em at 1000 upm
f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
cmap = f.getBestCmap()
hmtx = f["hmtx"]
gs = f.getGlyphSet()
def glyph_path(ch, dx):
"""Return SVG path 'd' for ch, shifted by dx in font units (y still up)."""
g = cmap[ord(ch)]
pen = SVGPathPen(gs)
tpen = TransformPen(pen, (1, 0, 0, 1, dx, 0))
gs[g].draw(tpen)
return pen.getCommands(), hmtx[g][0]
def o_metrics():
g = cmap[ord("o")]
bp = BoundsPen(gs); gs[g].draw(bp)
xmin,ymin,xmax,ymax = bp.bounds
return hmtx[g][0], xmin, ymin, xmax, ymax
# ---- layout the wordmark ---------------------------------------------------
x = 0.0
G_d, adv = glyph_path("G", x); x += adv + LS
# dial occupies the 'o' advance slot
o_adv, oxmin, oymin, oxmax, oymax = o_metrics()
o_left = x
cx = o_left + (oxmin + oxmax) / 2.0
cy = (oymin + oymax) / 2.0
R = ((oxmax - oxmin) + (oymax - oymin)) / 4.0 # avg radius, matches the 'o'
x += o_adv + LS
sentry_d = []
for ch in "Sentry":
d, adv = glyph_path(ch, x)
sentry_d.append(d)
x += adv + LS
x -= LS # no trailing letter-spacing
SENTRY_D = " ".join(sentry_d)
# ---- dial geometry (matched to the 'o', proportions from the HTML mock) ----
SW = R * 0.30 # ring stroke width
Rmid = R - SW / 2.0 # centreline radius of ring
HW = R * 0.21 # hand width
hour_len = R * 0.54 # 12 o'clock hand
min_len = R * 0.46 # ~4 o'clock hand
min_ang = 62 # degrees clockwise from 12
import math
mx = cx + min_len * math.sin(math.radians(min_ang))
my = cy + min_len * math.cos(math.radians(min_ang)) # font-up: +y is up
cap = R * 0.14
dial = f'''<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{Rmid:.1f}" fill="none" stroke="{AMBER}" stroke-width="{SW:.1f}"/>
<path d="M{cx:.1f} {cy:.1f} L{cx:.1f} {cy+hour_len:.1f}" stroke="{AMBER}" stroke-width="{HW:.1f}" stroke-linecap="round"/>
<path d="M{cx:.1f} {cy:.1f} L{mx:.1f} {my:.1f}" stroke="{AMBER}" stroke-width="{HW:.1f}" stroke-linecap="round"/>
<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{cap:.1f}" fill="{AMBER}"/>'''
# ---- overall bounds (font units, y up) -------------------------------------
bp = BoundsPen(gs)
xall = 0.0
gG = cmap[ord("G")]; gs[gG].draw(TransformPen(bp,(1,0,0,1,0,0)))
xall += hmtx[gG][0] + LS + o_adv + LS
for ch in "Sentry":
g = cmap[ord(ch)]
gs[g].draw(TransformPen(bp,(1,0,0,1,xall,0)))
xall += hmtx[g][0] + LS
bx0,by0,bx1,by1 = bp.bounds
# include the dial extents
bx0 = min(bx0, cx-R-SW/2); bx1 = max(bx1, cx+R+SW/2)
by0 = min(by0, cy-R-SW/2); by1 = max(by1, cy+R+SW/2)
PAD = 60
W = (bx1 - bx0) + 2*PAD
H = (by1 - by0) + 2*PAD
# transform: font(x,y up) -> screen: translate then flip y
tx = PAD - bx0
ty = PAD + by1
transform = f"matrix(1 0 0 -1 {tx:.2f} {ty:.2f})"
def svg(sentry_color, bg=None, name=""):
bgrect = f'<rect width="{W:.1f}" height="{H:.1f}" fill="{bg}"/>\n' if bg else ""
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:.1f} {H:.1f}" role="img" aria-label="GoSentry">
{bgrect}<g transform="{transform}">
<path d="{G_d}" fill="{AMBER}"/>
<path d="{SENTRY_D}" fill="{sentry_color}"/>
{dial}
</g>
</svg>
'''
variants = {
"gosentry-logo.svg": svg(PETROL), # light bg, transparent
"gosentry-logo-dark.svg": svg(WHITE), # dark bg, transparent
"gosentry-logo-onlight.svg": svg(PETROL, bg="#FFFFFF"),
"gosentry-logo-ondark.svg": svg(WHITE, bg="#04262E"),
"gosentry-logo-mono.svg": svg(PETROL).replace(AMBER, PETROL), # single-colour petrol
}
for fn, data in variants.items():
with open(os.path.join(OUT, fn), "w", encoding="utf-8") as fh:
fh.write(data)
print("wrote", fn)
print("viewBox %.1f x %.1f" % (W, H))
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
<g transform="matrix(1 0 0 -1 8.00 774.00)">
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#F7A80C"/>
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#FFFFFF"/>
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#F7A80C" stroke-width="77.8"/>
<path d="M937.5 247.0 L937.5 387.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
<path d="M937.5 247.0 L1042.8 303.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
<circle cx="937.5" cy="247.0" r="36.3" fill="#F7A80C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
<g transform="matrix(1 0 0 -1 8.00 774.00)">
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#0A4A58"/>
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#0A4A58"/>
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#0A4A58" stroke-width="77.8"/>
<path d="M937.5 247.0 L937.5 387.0" stroke="#0A4A58" stroke-width="54.4" stroke-linecap="round"/>
<path d="M937.5 247.0 L1042.8 303.0" stroke="#0A4A58" stroke-width="54.4" stroke-linecap="round"/>
<circle cx="937.5" cy="247.0" r="36.3" fill="#0A4A58"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4326.0 1034.0" role="img" aria-label="GoSentry">
<g transform="matrix(1 0 0 -1 8.00 774.00)">
<path d="M312.0 -14Q238.0 -14 179.5 18.5Q121.0 51 86.5 113.5Q52.0 176 52.0 267V433Q52.0 569 128.0 641.5Q204.0 714 332.0 714Q460.0 714 529.0 645.5Q598.0 577 598.0 461V457H480.0V465Q480.0 505 464.0 537.0Q448.0 569 415.0 587.5Q382.0 606 332.0 606Q258.0 606 215.5 560.5Q173.0 515 173.0 435V265Q173.0 186 215.5 139.0Q258.0 92 334.0 92Q410.0 92 445.0 133.0Q480.0 174 480.0 238V250H303.0V352H598.0V0H488.0V69H471.0Q463.0 51 446.0 31.5Q429.0 12 397.5 -1.0Q366.0 -14 312.0 -14Z" fill="#F7A80C"/>
<path d="M1524.0 -14Q1444.0 -14 1382.5 14.5Q1321.0 43 1286.0 97.0Q1251.0 151 1251.0 229V255H1370.0V229Q1370.0 160 1412.0 126.0Q1454.0 92 1524.0 92Q1595.0 92 1631.0 121.0Q1667.0 150 1667.0 196Q1667.0 227 1650.0 246.5Q1633.0 266 1600.5 278.0Q1568.0 290 1522.0 301L1492.0 307Q1423.0 323 1372.5 347.5Q1322.0 372 1295.0 411.0Q1268.0 450 1268.0 513Q1268.0 576 1298.0 621.0Q1328.0 666 1383.0 690.0Q1438.0 714 1512.0 714Q1586.0 714 1644.0 689.0Q1702.0 664 1735.5 614.0Q1769.0 564 1769.0 489V456H1650.0V489Q1650.0 532 1633.0 558.0Q1616.0 584 1585.0 596.0Q1554.0 608 1512.0 608Q1450.0 608 1418.0 584.0Q1386.0 560 1386.0 516Q1386.0 488 1400.5 468.5Q1415.0 449 1443.5 436.5Q1472.0 424 1515.0 415L1545.0 408Q1617.0 392 1671.0 367.5Q1725.0 343 1755.5 303.0Q1786.0 263 1786.0 199Q1786.0 136 1753.5 88.0Q1721.0 40 1662.5 13.0Q1604.0 -14 1524.0 -14Z M2091.0 -14Q2017.0 -14 1960.5 17.5Q1904.0 49 1872.5 106.5Q1841.0 164 1841.0 241V253Q1841.0 331 1872.0 388.0Q1903.0 445 1959.0 476.5Q2015.0 508 2088.0 508Q2160.0 508 2214.0 476.5Q2268.0 445 2298.0 388.0Q2328.0 331 2328.0 255V214H1957.0Q1959.0 156 1998.0 121.0Q2037.0 86 2094.0 86Q2150.0 86 2177.0 110.5Q2204.0 135 2218.0 166L2313.0 117Q2299.0 90 2272.5 59.5Q2246.0 29 2202.0 7.5Q2158.0 -14 2091.0 -14ZM1958.0 301H2211.0Q2207.0 350 2173.5 379.0Q2140.0 408 2087.0 408Q2032.0 408 1999.0 379.0Q1966.0 350 1958.0 301Z M2416.0 0V494H2529.0V425H2546.0Q2559.0 453 2593.0 478.0Q2627.0 503 2696.0 503Q2753.0 503 2797.0 477.0Q2841.0 451 2865.5 405.0Q2890.0 359 2890.0 296V0H2775.0V287Q2775.0 347 2745.5 376.5Q2716.0 406 2662.0 406Q2601.0 406 2566.0 365.5Q2531.0 325 2531.0 250V0Z M3189.0 0Q3141.0 0 3112.5 28.5Q3084.0 57 3084.0 106V399H2955.0V494H3084.0V653H3199.0V494H3341.0V399H3199.0V125Q3199.0 95 3227.0 95H3326.0V0Z M3428.0 0V494H3541.0V437H3558.0Q3569.0 468 3595.0 482.0Q3621.0 496 3657.0 496H3717.0V394H3655.0Q3605.0 394 3574.0 367.5Q3543.0 341 3543.0 286V0Z M3844.0 -200V-100H4117.0Q4145.0 -100 4145.0 -70V68H4128.0Q4120.0 50 4102.0 32.5Q4084.0 15 4054.0 3.5Q4024.0 -8 3978.0 -8Q3921.0 -8 3877.0 17.5Q3833.0 43 3809.0 89.5Q3785.0 136 3785.0 198V494H3899.0V207Q3899.0 147 3928.5 118.0Q3958.0 89 4012.0 89Q4073.0 89 4108.5 129.0Q4144.0 169 4144.0 244V494H4258.0V-94Q4258.0 -143 4230.0 -171.5Q4202.0 -200 4154.0 -200Z" fill="#0A4A58"/>
<circle cx="937.5" cy="247.0" r="220.4" fill="none" stroke="#F7A80C" stroke-width="77.8"/>
<path d="M937.5 247.0 L937.5 387.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
<path d="M937.5 247.0 L1042.8 303.0" stroke="#F7A80C" stroke-width="54.4" stroke-linecap="round"/>
<circle cx="937.5" cy="247.0" r="36.3" fill="#F7A80C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Rasterize the GoSentry wordmark to PNG at several widths, reusing the
same font outlines + dial geometry as gen_logo.py (no SVG rasterizer needed)."""
import os, math
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.path import Path
from matplotlib.patches import PathPatch, Circle
from matplotlib.lines import Line2D
from fontTools.ttLib import TTFont
from fontTools.pens.basePen import BasePen
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.transformPen import TransformPen
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
AMBER, PETROL, WHITE = "#F7A80C", "#0A4A58", "#FFFFFF"
LS = -30
f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
cmap, hmtx, gs = f.getBestCmap(), f["hmtx"], f.getGlyphSet()
class MplPen(BasePen):
def __init__(self, glyphSet):
super().__init__(glyphSet); self.v=[]; self.c=[]
def _moveTo(self,p): self.v.append(p); self.c.append(Path.MOVETO)
def _lineTo(self,p): self.v.append(p); self.c.append(Path.LINETO)
def _curveToOne(self,p1,p2,p3):
self.v += [p1,p2,p3]; self.c += [Path.CURVE4]*3
def _qCurveToOne(self,p1,p2):
self.v += [p1,p2]; self.c += [Path.CURVE3]*2
def _closePath(self):
self.v.append((0,0)); self.c.append(Path.CLOSEPOLY)
def glyph_mplpath(ch, dx):
pen = MplPen(gs)
gs[cmap[ord(ch)]].draw(TransformPen(pen,(1,0,0,1,dx,0)))
return Path(pen.v, pen.c), hmtx[cmap[ord(ch)]][0]
# layout
x=0.0
paths_amber=[]; paths_petrol=[]
p,adv = glyph_mplpath("G",x); paths_amber.append(p); x+=adv+LS
og=cmap[ord("o")]; bp=BoundsPen(gs); gs[og].draw(bp)
oxmin,oymin,oxmax,oymax=bp.bounds; o_adv=hmtx[og][0]
cx=x+(oxmin+oxmax)/2; cy=(oymin+oymax)/2
R=((oxmax-oxmin)+(oymax-oymin))/4
x+=o_adv+LS
for ch in "Sentry":
p,adv=glyph_mplpath(ch,x); paths_petrol.append(p); x+=adv+LS
x-=LS
SW=R*0.30; Rmid=R-SW/2; HW=R*0.21
hour_len=R*0.54; min_len=R*0.46; ang=math.radians(62)
mx=cx+min_len*math.sin(ang); my=cy+min_len*math.cos(ang); cap=R*0.14
# bounds
allb=BoundsPen(gs); gs[cmap[ord('G')]].draw(allb)
xx=hmtx[cmap[ord('G')]][0]+LS+o_adv+LS
for ch in "Sentry":
gs[cmap[ord(ch)]].draw(TransformPen(allb,(1,0,0,1,xx,0))); xx+=hmtx[cmap[ord(ch)]][0]+LS
bx0,by0,bx1,by1=allb.bounds
bx0=min(bx0,cx-R-SW/2); bx1=max(bx1,cx+R+SW/2)
by0=min(by0,cy-R-SW/2); by1=max(by1,cy+R+SW/2)
PAD=60
X0,X1=bx0-PAD,bx1+PAD; Y0,Y1=by0-PAD,by1+PAD
W=X1-X0; H=Y1-Y0
def render(path_png, width_px, sentry_color, bg=None, mono=False):
dpi=100
fw=width_px/dpi; fh=fw*H/W
fig=plt.figure(figsize=(fw,fh),dpi=dpi)
ax=fig.add_axes([0,0,1,1]); ax.set_xlim(X0,X1); ax.set_ylim(Y0,Y1)
ax.set_aspect('equal'); ax.axis('off')
if bg: fig.patch.set_facecolor(bg); ax.set_facecolor(bg)
else: fig.patch.set_alpha(0)
amberc = sentry_color if mono else AMBER
for p in paths_amber: ax.add_patch(PathPatch(p,facecolor=amberc,edgecolor='none',antialiased=True))
for p in paths_petrol: ax.add_patch(PathPatch(p,facecolor=sentry_color,edgecolor='none',antialiased=True))
pt_per_unit = fw/W*72
ax.add_patch(Circle((cx,cy),Rmid,fill=False,edgecolor=amberc,linewidth=SW*pt_per_unit))
for (ex,ey) in [(cx,cy+hour_len),(mx,my)]:
ax.add_line(Line2D([cx,ex],[cy,ey],color=amberc,linewidth=HW*pt_per_unit,
solid_capstyle='round'))
ax.add_patch(Circle((cx,cy),cap,facecolor=amberc,edgecolor='none'))
fig.savefig(path_png,dpi=dpi,transparent=(bg is None))
plt.close(fig)
print("wrote",os.path.basename(path_png))
for w in (256,512,1024,2048):
render(os.path.join(OUT,f"gosentry-logo-{w}.png"),w,PETROL)
render(os.path.join(OUT,f"gosentry-logo-dark-{w}.png"),w,WHITE)
render(os.path.join(OUT,"gosentry-logo-onlight-1024.png"),1024,PETROL,bg="#FFFFFF")
render(os.path.join(OUT,"gosentry-logo-ondark-1024.png"),1024,WHITE,bg="#04262E")
render(os.path.join(OUT,"gosentry-logo-mono-1024.png"),1024,PETROL,mono=True)
print(f"aspect {W:.0f}x{H:.0f}")
+4 -4
View File
@@ -3,15 +3,15 @@ package main
import (
"os"
"gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/gui"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/ui"
)
func main() {
// The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific
// UI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic.
gui.Run(hasArgument(core.StartInTrayArgument))
ui.Run(hasArgument(domain.StartInTrayArgument))
}
func hasArgument(argument string) bool {
+193 -39
View File
@@ -1,73 +1,227 @@
# GoSentry Architecture
This document shows the current component interaction model. GoSentry is still a
single desktop process: the GUI, scheduler, storage, and command runner live in
one application and communicate through Go function calls and shared in-memory
job state.
This document shows the current component interaction model. GoSentry is a
single desktop process: the GUI, application service, scheduler, storage, and
command runner live in one application. They communicate through typed events
and well-defined interfaces rather than shared mutable state.
## Package Map
```
cmd/gosentry entry point — starts the UI
src/
domain/ pure value types: Job, Config, RunRecord, Schedule, JobRuntime
app/ Service — sole owner of job/runtime state; emits typed Events
scheduler/ pure timing loop; calls app.Service.RunDue on every tick
runner/ shell command execution + log file writing + cleanup
storage/ JSON persistence (gosentry.json, jobs.json)
platform/
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
desktop/ desktop entry + icon under XDG data home (Linux only)
filemanager/ open a folder in the desktop file manager
winproc/ hidden-window startup flags (Windows only)
ui/ Fyne windows, tabs, and dialogs; reads service via Events
```
## Component Diagram
```mermaid
flowchart LR
user["Desktop user"]
gui["src/gui - Fyne windows, tabs, dialogs"]
store["src/core Store - YAML config and jobs"]
scheduler["src/core Scheduler - @every and cron timing"]
runner["src/core Runner - shell command execution"]
autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"]
config["gosentry.yaml - application settings"]
jobs["jobs.yaml - job definitions"]
ui["src/ui - Fyne windows, tabs, dialogs"]
svc["src/app Service - sole owner of job + runtime state"]
store["src/storage Store - JSON config and jobs"]
sched["src/scheduler Scheduler - pure timing loop"]
runner["src/runner - shell command execution"]
autostart["src/platform/autostart Manager - Windows shortcut / Linux XDG"]
config["gosentry.json - application settings"]
jobs["jobs.json - job definitions"]
logs["logs_dir - per-run command output logs"]
shell["Platform shell - cmd.exe /C or sh -c"]
user -->|"edits jobs, settings, runs commands"| gui
gui -->|"OpenStore, SaveConfig, SaveJobs"| store
user -->|"edits jobs, settings, runs commands"| ui
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
store -->|"read/write"| config
store -->|"read/write"| jobs
gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler
scheduler -->|"SaveJobs after state changes"| store
scheduler -->|"RunJob(trigger)"| runner
svc -->|"Start(RunDue)"| sched
sched -->|"RunDue(now)"| svc
svc -->|"RunJob"| runner
runner -->|"execute command"| shell
runner -->|"write stdout/stderr log"| logs
runner -->|"RunRecord with status, duration, log path"| scheduler
scheduler -->|"onChange RunRecord"| gui
gui -->|"display History, command output, job state"| user
runner -->|"RunRecord"| svc
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
ui -->|"display jobs, history, status"| user
gui -->|"SetAutostart, AutostartStatus"| autostart
autostart -->|"use executable path from resolved Paths"| config
svc -->|"Set / Status via Manager"| autostart
```
## Main Flows
1. Startup:
The executable starts `cmd/gosentry`, which calls the GUI package. The GUI
opens the store, loads `gosentry.yaml` and `jobs.yaml`, creates the main tabs,
then starts the scheduler with the loaded job slice.
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
events, builds the main window, and calls `Service.Start` to begin the
scheduler loop. On every launch the service seeds per-job run-time statistics
from existing log files so the details panel reflects accumulated history
immediately (see §Statistics below).
2. Editing settings or jobs:
The GUI updates the in-memory job/config state and asks `Store` to write YAML
back to disk. Job definitions stay in one `jobs.yaml`; runtime command output
is not stored there.
The UI calls mutating methods on `app.Service` (e.g. `CreateJob`,
`UpdateJob`, `UpdateSettings`). The Service validates the request, updates
its in-memory state, persists through `storage.Store`, and emits a typed
`Event`. The UI's observer receives the event and refreshes the relevant
widget on the main thread via `fyne.Do`.
`UpdateSettings` has one extra step: when the configured jobs file changes
and a file already exists at the new path, that file is authoritative. The
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
map, schedule cache, next-run times, and log-seeded statistics around it, and
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
receives the current jobs instead. Adoption drops all runtime state, so it is
refused while a job is running.
3. Scheduled run:
`Scheduler` checks due jobs on a one-second ticker. When a job is due, it marks
the job as running, saves state, and starts `Runner` asynchronously.
`scheduler.Scheduler` fires a tick every second. On each tick it calls
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
due, marks each as running, and launches `runner.RunJob` in a goroutine.
4. Manual run:
`Run now` calls the same scheduler path as scheduled execution, but the
resulting history record uses the `Manual` trigger.
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
exists, is not already running, and (in sequential mode) that no other job is
running, then executes `runner.RunJob` with the `Manual` trigger. Manual runs
are allowed even while the scheduler is globally paused.
5. Command execution:
`Runner` executes the command through the platform shell, captures stdout and
stderr, writes one timestamped `.log` file, and returns a `RunRecord`.
`runner.RunJob` builds the platform-specific invocation, executes the
command through the platform shell under the caller-supplied timeout, captures
stdout and stderr, writes one timestamped `.log` file, and returns a
`domain.RunRecord` containing
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
fire-and-forget jobs it measures launch latency — the time to spawn the
process — since there is no exit to wait for).
6. History update:
The scheduler receives the `RunRecord`, updates the matching job, saves YAML,
runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
When a run goroutine completes, `Service` updates the job's runtime
(including the statistics aggregate), saves JSON, triggers log cleanup, and
emits `RunRecorded`. The UI observer appends the record to the History tab.
History rows exist only for the current process session; restarting the app
clears the table (aggregate stats in the details panel are still seeded from
log files).
7. Autostart:
The Settings tab calls the platform autostart implementation. Windows uses a
shortcut in the current user's Startup folder. Linux uses a desktop-session
startup entry. Both autostart mechanisms pass `--start-in-tray`, so the
scheduler starts without opening the main window after sign-in.
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
interface has two implementations: Windows writes a `.lnk` shortcut to the
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
entries pass `--start-in-tray`.
8. Error surfacing:
Background errors (failed JSON saves, cleanup errors) are emitted as
`ErrorOccurred` events and displayed in the UI status area, rather than
being silently discarded.
## Key Domain Concepts
### Per-job overlap policy
`domain.Job` carries an `OverlapPolicy` field (`json:"overlap_policy,omitempty"`).
When non-empty it overrides the global `Config.OverlapPolicy` for that job alone.
Empty means inherit the global default. `app.Service.RunDue` resolves the
effective policy per job: it uses `job.OverlapPolicy` when set, otherwise falls
back to `store.Config.OverlapPolicy`. `normalizeJob` in `app/operations.go` leaves
the field empty on new jobs so the inherit semantics are preserved.
Under the `"queue"` policy, each occurrence that fires while a run is still
in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
`executeRun` drains the counter by starting one deferred run per completion until
`PendingRuns` reaches zero.
### Per-job command timeout
`domain.Job` carries a `TimeoutSeconds *int` field
(`json:"timeout_seconds,omitempty"`), following the same inherit pattern as the
overlap policy. It is a **pointer** because the setting has three states that
must stay distinguishable on disk:
| `Job.TimeoutSeconds` | jobs.json | Meaning |
| --- | --- | --- |
| `nil` | field absent | inherit `Config.DefaultTimeoutSeconds` |
| `0` | `"timeout_seconds": 0` | no timeout, does **not** inherit |
| `> 0` | `"timeout_seconds": 45` | per-job limit in seconds |
The global `Config.DefaultTimeoutSeconds` (default **0**, i.e. no timeout) is
written unconditionally — no `omitempty` — for the same reason: `0` there is a
deliberate choice, not a missing value, and `storage.loadOrCreateConfig` must not
normalize it away. `app.Service.effectiveTimeout`
resolves the effective duration under `mu` and `startRunLocked` snapshots it into
`runEnv.timeout`. `runner.RunJob(ctx, job, trigger, logsDir, timeout)` takes the
resolved duration as an argument, so the runner stays ignorant of the global
config: a positive duration applies the timeout via `context.WithTimeout` and
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
the untimed context and so measure launch latency only, unaffected by the run
timeout.
### Run-time statistics
`domain.JobRuntime` holds a rolling aggregate updated after each run:
| Field | Meaning |
|-------|---------|
| `RunCount` | total runs recorded |
| `FailCount` | runs that exited non-zero |
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
| `AvgDurationMS` | mean over all runs with a recorded duration |
| `MaxDurationMS` | longest recorded run |
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
log file header alongside the existing `state` line.
On startup, `runner.SeedStats` scans log files (matched primarily by the
`job_id` header, with a sanitized-name filename fallback for legacy logs,
bounded by `Config.MaxLogFiles`) and folds the parsed `state`/`duration`
headers into a `runner.SeededStats` map. `NewService` applies those seeds to
the runtime map before the first scheduler tick, so the details panel shows
accumulated run history immediately after a restart.
Older log files that pre-date the `duration` header are tolerated: the run is
counted but the timing is skipped.
`JobRuntime.Logs` (per-run `RunRecord` entries shown in the History tab) is
**session-only**: it is not written to `jobs.json` and is not rebuilt from
`.log` files on startup. Log files on disk feed aggregate counters via
`SeedStats` only. See [STANDARDS.md](STANDARDS.md).
### Persisted global pause
`domain.Config` carries a `Paused bool` field (`json:"paused,omitempty"`).
`app.Service.SetGlobalPause` writes the new value into `store.Config` and calls
`SaveConfig`, so the paused state survives a restart. `NewService` initialises
`s.paused` from `store.Config.Paused` and applies the paused next-run text to
all runtimes before the first tick, ensuring the UI shows the correct state from
the moment the window opens.
### `jobs_view.go` file structure
The size guideline for a file in this project is ~250 lines.
`src/ui/jobs_view.go` is split across three files along these seams; the view
file itself has grown back over the guideline since — see the split item in
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
| File | Contents |
|------|----------|
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
### `settings_view.go` file structure
`src/ui/settings_view.go` is split across three files the same way, once its
own size passed the guideline:
| File | Contents |
|------|----------|
| `settings_view.go` | `settingsView` — field construction, save, load, validate; the Theme label translation helpers |
| `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) |
+626
View File
@@ -2,6 +2,632 @@
All notable GoSentry changes are recorded in this file.
## 1.0.0 - 2026-07-27
**The window opens at the size it asks for, and the Jobs divider can be
dragged.**
**Window:**
- **The window opens at 1024×660 and can now be dragged narrower than it opens.**
Fyne treats the assembled content's minimum size as a hard floor over the
requested size, and two widgets in Settings pushed that minimum past 1024 px:
a fixed width applied to seven controls that the layout already stretched, and
the read-only config path, which grew the whole tab with the length of the
path it was showing — a 75-character path alone demanded 1501 px. The path now
clips when the window is genuinely narrow instead of widening the window, and
the content minimum is 972 px.
**Jobs:**
- **The divider between the job list and the details pane is draggable.**
Previously the list was pinned at its natural width and the details pane took
whatever was left, so a long command or a deep folder path could not be given
more room. Either pane can now be widened at the other's expense, and neither
can be dragged below its own content, so the details pane condenses rather
than clipping. The divider opens at the list's natural width; its position is
not saved, so a restart reopens at that default.
**History:**
- **Columns measure their own content.** Time, Trigger and State were fixed
pixel widths with as little as 1.6 px of headroom and truncated their own
values on a scaled UI or at a larger text size; all five now size themselves
from the text they have to show, under the current theme. Job and Detail stay
bounded so one long row cannot take over the table.
**Settings:**
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
its layout always intended, rather than 8.
- The caption column is as wide as the widest caption instead of a fixed width,
which gives each value column about 22 px more and keeps the captions readable
at a larger text size.
- The **Application** and **About** blocks are about 2 px tighter: every stacked
row group in the app now shares one spacing derived from the theme rather than
three separately tuned numbers.
- The **Theme** dropdown is no longer flush against the **Notifications**
checkbox. That shared row spacing pulls rows together by one text inset, which
the rows above have to give but a dropdown — which paints its box out to the
row's edge — does not, so the gap collapsed to about a pixel. The Theme row
now keeps the same gap the checkbox rows have.
**Documentation:**
- The **README** describes the application that exists. Its `gosentry.json`
sample was three keys short of what the app writes on first run, which made
the one file the user is invited to hand-edit the least accurate thing in the
document; it is now the real default, with each key explained — including why
a zero timeout is written out and an unset one is not. The feature list has
caught up with the run timeout, the theme, the compact job list, and the
per-job overlap and timeout overrides the job dialog has always offered.
- **`docs/DEVELOPMENT.md`** is ordered as stack, external libraries, run from
source, build, release, CI, behind a two-level table of contents, instead of
opening with MSYS2 setup and burying "Run From Source" mid-document. The
library table gains versions and licenses, the `package-*` scripts are
documented for the first time and labelled by OS, and the Codeberg
`RELEASE_TOKEN` note now states the failure mode rather than leaving it to be
inferred from a red job: build and packaging succeed, the upload step fails on
authentication and takes the job with it, leaving a published release with no
assets. The Project Layout section is gone — it duplicated ARCHITECTURE's
package map and had drifted out of date.
- **Cutting a GitHub release now documents the push mirror it has to survive.**
GitHub is a pruning push mirror of Gitea, so `gh release create` creating the
tag itself produces a tag Gitea does not know about, which the next
synchronisation deletes — orphaning the release and taking its uploaded
archives with it, without a single failed step to point at. The procedure is
push the tag to Gitea, wait for the mirror, verify the tag on GitHub, then
publish with `--verify-tag`.
- **`docs/TESTS.md`** matches the suite it indexes again. It listed 130 tests
against 170 in the tree, omitted four test files entirely, and named two tests
that no longer exist. Every test function now appears exactly once, under the
file it actually lives in.
- **`docs/ARCHITECTURE.md`** no longer draws the UI calling the autostart
manager directly — it does not, and `src/ui` holds no reference to that
package — and `platform/desktop` is described by what it does (the XDG desktop
entry and icon) rather than as a display-scale helper.
- The **~250-line file guideline** is stated as the target it is, with the six
files currently over it recorded as a `docs/ROADMAP.md` item. They are to be
split in one pass during the next whole-project review, since six separate
passes would settle the same seam question six ways.
## 0.15.0 - 2026-07-26
**Settings points at the jobs file itself, not the folder holding it.**
**Settings:**
- The **Jobs directory** row is now a **Jobs file** row. Browse opens a file
picker filtered to `.json` instead of a folder picker, so the job list can
live under any file name — `team-jobs.json`, one file per machine, a file
shared over a network drive — rather than a fixed `jobs.json` per folder. The
field stays editable, which is how a file that does not exist yet is named.
- **Selecting an existing jobs file now loads it.** Previously the current job
list was written over whatever was at the new path, which made it impossible
to switch to an existing jobs file — its contents were destroyed on Save. Now
an existing file wins: its jobs are loaded, normalized, and replace the loaded
list, with runtimes, parsed schedules, next-run times, and log-seeded
statistics rebuilt around them. A path with no file behind it still receives
the current jobs (and its folder is created), which is how the jobs file is
renamed or relocated. History records `Jobs loaded — N jobs from <path>`,
since the switch happens without a prompt.
- Switching to a different jobs file is refused while a job is running: adoption
discards every runtime, and a run finishing afterwards would write its result
onto whichever job inherited its ID. Settings unrelated to the jobs file still
save normally during a run.
- Saving a path with no file name (a trailing separator, `.`, `..`) is rejected
with "jobs file must include a file name" instead of failing later with an
opaque OS error.
**Configuration:**
- `Config.JobsDir` / `jobs_dir` is replaced by `Config.JobsFile` / `jobs_file`,
which holds the full path including the file name; the default is
`"jobs.json"`, resolved against the program folder as before. `Paths.JobsDir`
is now derived from the configured file so job saves still create the folder.
- A `gosentry.json` written by an earlier version is migrated on load: its
`jobs_dir` is joined with `jobs.json`, which is the exact file that version
used, and the retired key is dropped when the config is rewritten.
- New `app.JobsLoaded{Path, Count}` event, emitted when a selected jobs file
replaces the job list; the UI turns it into the History entry. New
`storage.LoadJobsFile`, which reads and normalizes a jobs file and reports a
missing one as "not found" instead of seeding it the way startup does.
## 0.14.0 - 2026-07-26
**Compact job list view, "no timeout" at both timeout levels, and an Open
button for the logs folder.**
**Compact job list view.**
- The Jobs sidebar can now render each job as a single line — name on the left,
status on the right — instead of the three-line block. A toggle button beside
the Folder filter switches between **Compact** and **Detailed**; it is
labelled with the action it performs, like the "Disable auto" button. Compact
fits many more jobs on screen without scrolling; selection, the details panel,
the folder filter, and live status updates all work unchanged in both modes.
- The choice is persisted as a new `Config.JobListView` field
(`"detailed"` / `"compact"`, written to `gosentry.json` as `job_list_view`),
so it survives a restart. Empty/legacy configs and any unrecognised value
normalize to detailed, so existing installs keep the current look.
**Jobs sidebar:**
- The **Folder** caption moved onto the filter row itself, beside the select and
the view toggle, instead of occupying its own line above it — the job list now
starts a full label higher.
**Settings:**
- The **Logs directory** row gained an **Open** button that shows the folder in
the desktop file manager (Explorer on Windows, the XDG handler on Linux), so
reading a log file no longer means copying the path by hand. It opens the
path currently in the field — including an edit that has not been saved yet —
resolving a relative directory against the application folder exactly as the
store does. A folder that is missing (the logs directory is created on the
first run) or cannot be opened is reported in a dialog.
- The Save/Cancel/Defaults row sat flush against the separator above it and the
tab's left edge; it now uses the same padding as the other vertical gaps in
the tab.
**Job dialog:**
- The **Arguments** placeholder now states the field's rule — one argument per
line, no quoting — instead of showing a lone example path that left the
line-per-argument convention to guesswork.
**Timeouts: 0 now means "no timeout" at both levels.**
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs
run to completion with no deadline instead of being killed after 30s.
- A per-job timeout of `0` now also means "no timeout" and no longer inherits
the global default. Leaving the job's timeout **empty** is what inherits.
`Job.TimeoutSeconds` became `*int` so the three states — unset, explicit 0,
and a positive limit — stay distinguishable in `jobs.json`.
- Fixed: a global default of `0` did not survive a restart. `gosentry.json` was
loaded with `0` treated as a missing value and silently reset to 30s, so the
setting only held for the current session. `default_timeout_seconds` is now
written unconditionally and read back as-is.
Existing jobs and configs are unaffected: a job with no `timeout_seconds` still
inherits, and a saved global default of 30 stays 30.
**Internal:**
- Job names in the list are truncated through the widget's `Truncation` field;
`fyne.TextTruncate` is deprecated in Fyne 2.7.4. Behavior is unchanged.
- Docker release builds mount `.gocache/` from the host, so `--rm` container
removal no longer wipes `GOCACHE` between runs.
- Added `docs/REVIEW.md` (the project-review agenda) and a "Config file
compatibility" section in `docs/STANDARDS.md` recording the rule the `Theme`,
`JobListView`, and `TimeoutSeconds` fields already follow. Added `CLAUDE.md`.
## 0.13.0 - 2026-07-26
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
**Theme:**
- Added a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants; users can switch between it and Fyne's default theme from
Settings. The choice is persisted as a new `Config.Theme` field
(`"default"` / `"gosentry"`), applied at startup before the first frame
and live-previewed when picked in Settings. Empty/legacy configs
normalize to the default theme so existing installs keep the original
look.
- The light variant is boldly branded: a soft teal window canvas with
white inputs, menus, dialogs, and buttons on top, plus teal-tinted
separators, input borders, and table headers, so cards and fields lift
off the background instead of reading as a plain accent swap on gray.
The dark variant uses deep-teal surfaces to echo the app icon. Text
stays dark/light per the base foreground for contrast in both variants.
**Settings tab:**
- Added Cancel and Defaults buttons. Cancel discards unsaved edits by
reloading the saved config; Defaults loads built-in default values into
the form for review before saving. `domain.DefaultConfig()` is now the
single source of truth for default values, shared by storage and the
Settings UI.
## 0.12.0 - 2026-07-25
**Per-job command timeout:**
- Each job may now set its own run timeout (seconds) in the job dialog; leaving
it empty inherits a new **Default timeout** in Settings (default 30s), the same
inherit pattern as the overlap policy. The details panel shows the effective
value, marking inherited jobs as `(global default)`.
- The formerly hard-coded 30s guard in `runner.RunJob` is now the configurable
default. `StartOnly` fire-and-forget jobs remain unaffected by the run timeout,
continuing to measure launch latency only.
## 0.11.5 - 2026-07-01
**Quality and documentation polish:**
- Replaced the interim `docs/FUTURE_WORK.md` with `docs/STANDARDS.md` — a slim,
permanent reference for code-quality rules and intentional behavior.
- `newMainView` now accepts an injected `*app.Service` for testability;
`RunNow` errors are shown in a dialog instead of failing silently.
- Empty job lists no longer panic when building the Jobs tab.
- Added regression and helper tests for overlap/pause scheduling, UI history
helpers, main-view smoke build, and Linux desktop integration.
## 0.11.4 - 2026-06-30
**Statistics:**
- `StartOnly` jobs now record launch latency (time to spawn the process) as the
run duration instead of a hard-coded `0`, so the Statistics line shows a real
last/avg/max for fire-and-forget jobs. Sub-millisecond launches still round to
0 and are excluded from the average, as before.
## 0.11.3 - 2026-06-29
**Reliability fixes from an internal code review: safer runs, a real overlap
queue, and more accurate statistics.**
**Scheduler / runs:**
- Fixed a data race where background runs could read log paths while settings
were being saved.
- A run no longer starts if persisting the "Running" state fails; the job rolls
back to its previous status instead.
- Under the `"queue"` overlap policy, every missed occurrence while a run is
still in flight is now remembered and executed afterward (not just the last one).
- Manual and scheduled runs now advance next-due timing from the scheduler clock
consistently.
**Application service:**
- Create, update, delete, enable/disable, and global pause no longer announce
UI changes when the underlying JSON save fails.
- Invalid per-job `overlap_policy` values are rejected at save time.
- Log file write failures are reported in History instead of failing silently.
**Statistics:**
- Startup stat seeding matches log files by `job_id`, avoiding collisions when
different job names sanitize to the same filename.
- Average run duration excludes zero-duration runs (such as StartOnly launches),
matching how stats are rebuilt from log files.
**Documentation:**
- Added `docs/CODE_REVIEW.md` with the full review summary.
- Corrected stale YAML references and clarified that global pause stops only
scheduled runs while manual "Run now" remains available.
## 0.11.2 - 2026-06-25
**Window state persistence, History sort fix, clearer scheduler toggle, and an
appID update.**
**Application:**
- The window size (width and height) is now persisted in preferences and
restored on next launch. When the user closes the window (via Quit menu or
window close button), the current dimensions are saved and will be applied
when the application starts again. Defaults to 1024×660 if no saved size exists.
- Updated appID from `ru.mixdep.gosentry.desktop` to `ru.mixeme.gosentry.desktop`
for consistency with the new domain name.
**History tab:**
- Fixed the Time column sort toggle, which stopped working after Fyne 2.7.4 began
rejecting header-cell selections. The plain header label is replaced with a
custom tappable header widget that handles the click directly.
- The sort direction is now shown with ▲/▼ glyphs instead of the "asc"/"desc"
text.
**Jobs list:**
- Renamed the global scheduler toggle from "Pause all"/"Resume all" to
"Disable auto"/"Enable auto", and swapped the stop icon for a pause icon, to
make clear that it only stops automatic scheduled runs.
## 0.11.1 - 2026-06-25
**Settings tab refinements: even spacing, full labels, and a smarter Save button.**
**Settings tab:**
- The Queue selects (Execution mode, Default overlap policy) now use the same
default spacing as the Storage fields, so they no longer sit squeezed together.
- Widened the caption column so the longest label, "Default overlap policy", is
shown in full instead of being truncated.
- The Save button now starts disabled and only enables once a field differs from
the saved config, re-disabling after a successful save (or if a changed field
is reverted to its original value).
## 0.11.0 - 2026-06-25
**Manual runs while paused, two-column Settings/details, and a more compact job list.**
**Scheduler:**
- "Run now" is now allowed while the scheduler is globally paused. The global
pause stops only automatic scheduled runs; an explicit manual run is the user's
own one-off action and is no longer blocked (the already-running and
sequential-mode guards still apply).
**Jobs details panel:**
- Metadata captions (Folder, Command, Run mode, …) are pinned to a fixed width
instead of an even split, so widening the window now grows the value column
rather than the short caption.
- Fixed a bug where the "Selected job activity" panel kept showing the previous
job's entries when a different job was selected; the list now refreshes on
every selection change.
**Jobs list:**
- List rows (name, schedule/command, status) are condensed with a tight,
negative-gap layout so more jobs are visible without scrolling.
**Settings tab:**
- The form is reorganized into two columns — Application and Queue on the left,
Storage and About on the right — with the Save button spanning the full width
below. The Autostart status moved onto its own line so the section fits a
half-width column.
- Removed the blank row that sat between the Save button and the following
separator.
## 0.10.2 - 2026-06-25
**Condensed details/settings panels and a window that shrinks to 720p.**
**Jobs details panel:**
- Job metadata is laid out in two columns (Folder/Schedule, Command/Arguments,
Run mode/Overlap policy, Last run/Next run, State/Statistics), roughly halving
the block height.
- Metadata rows are stacked with a tight, negative-gap layout so the interval
between rows is no longer oversized.
- The command-output area's minimum height was reduced so the details pane can
get shorter; long output still scrolls.
- The "Selected job activity" panel is now sized to exactly fit
`maxJobActivityRows`, derived from `widget.List`'s own row metrics, so all
three rows are visible without a scrollbar regardless of theme or DPI.
**Settings tab:**
- The form is wrapped in a vertical scroll so it no longer dictates the window's
minimum height (tab containers size to the tallest tab); it scrolls on short
screens instead.
- Label-only sections (Application, Queue, About) are condensed, while
separators and the editable Storage fields keep normal spacing so dividers
have breathing room and entry boxes stay visibly separated.
**Window sizing:**
- Together these changes drop the minimum window height from ~891px to ~570px,
so the window can be resized noticeably shorter and fits comfortably on 720p
screens.
## 0.10.1 - 2026-06-24
**Refactoring:**
- Unified icon asset naming from mixed scheme (big/16x16) to consistent
size-based names (large/small) for clarity and maintainability.
**Build:**
- Windows build script now displays informative messages at each build step
(version, output path, environment setup, icon embedding, compilation)
to improve build transparency and aid troubleshooting.
## 0.10.0 - 2026-06-24
**Compact activity rows; per-job execution-time statistics seeded from log files.**
**Activity panel (one-line rows):**
- Each entry in the job log list is now a single truncated line using only the
base name of the log file (e.g. `20260624-120000_Build.log`) instead of the
full path. Long lines are clipped rather than wrapped, keeping the panel
compact with many runs.
- History table retains the full log path for reference; base-name truncation
applies only to the activity rows in the Jobs details panel.
**Execution-time statistics:**
- Added `DurationMS` field to `RunRecord`; the runner measures wall-clock
start-to-finish and writes it to both the record and a `duration:` header
line in the log file. `StartOnly` jobs record `0`.
- Added aggregate counters to `JobRuntime`: `RunCount`, `FailCount`,
`LastDurationMS`, `AvgDurationMS`, `MaxDurationMS`. Updated after every
completed run in `executeRun`.
- On startup the statistics are seeded from existing log files: the runner
parses `state:` and `duration:` headers for each job's newest log files
(bounded by `MaxLogFiles`). Legacy logs without a `duration:` line still
count toward `RunCount`/`FailCount` but are excluded from duration
aggregates so a missing duration cannot appear as a zero-millisecond run.
- A **Statistics** row in the Jobs details panel shows a one-line summary
(`N runs, M failed, last X ms, avg Y ms, max X ms`) that refreshes after
each run and is pre-populated from log files after a restart.
**Per-job overlap policy:**
- Added an `OverlapPolicy` field to `domain.Job`; a job can now override the
global skip/queue default. `RunDue` resolves the effective policy per job
(the job's value if set, otherwise `Config.OverlapPolicy`).
- The job dialog gains an overlap-policy selector with a
"(Use global default)" option that saves empty so the job inherits the
global setting. The details panel reflects the effective policy.
**Persisted global pause:**
- Added `Paused` to the config so the global "Pause all" state survives a
restart. `SetGlobalPause` persists the new value, and the service initializes
its paused state from config at startup — a paused install now relaunches
paused instead of silently resuming the scheduler.
- The Pause-all/Resume-all button and scheduler-state label initialize from the
persisted state.
**Window sizing (720p-safe):**
- Lowered the default window size to `1024×660` with a sensible `MinSize` so the
window opens fully visible on a 1366×768 / 720p screen. Layout minimums in the
Jobs view were tightened to match.
**Packaging:**
- Added portable-distribution helpers: `scripts\package-windows.bat` builds and
bundles `gosentry.exe`, `README.md`, and `CHANGELOG.md` into a versioned
`.zip`; `scripts/package-linux.sh` does the same for `linux-amd64` and
`linux-arm64` into `.tar.gz` archives.
**Internal cleanup:**
- Split `ui/jobs_view.go` into focused files (`jobs_view_details.go`,
`jobs_view_helpers.go`) to bring it back under the file-size guideline.
- Removed the one-time YAML→JSON import path (shadow structs, `importYAML*`,
legacy path names) now that the 0.9.0 transition window has passed;
`go.yaml.in/yaml/v4` is dropped from `go.mod`.
- Post-field-test sweep of stale diagnostics, obsolete autostart-migration code,
and noisy README/ignore rules. The startup-timing History event is retained.
- Removed the completed release-milestone docs and trimmed `ROADMAP.md` to open
items only.
## 0.9.0 - 2026-06-24
**Storage migrated to JSON; queue execution modes; failure notifications; tray left-click; Fyne 2.7.4.**
**Storage and data model:**
- Settings and jobs now stored as `gosentry.json` and `jobs.json` (2-space indented JSON).
On first run after upgrading, existing `gosentry.yaml` / `jobs.yaml` files are imported
automatically and rewritten as JSON; the YAML files are not deleted.
- Removed `SuccessExitCodes` field. Exit-code handling is now fixed: exit code 0 = success,
any nonzero exit code = failure. Jobs relying on nonzero success codes must switch to
`StartOnly` mode if the exit code is irrelevant.
**Execution modes and overlap policies:**
- Added `ExecutionMode` (parallel/sequential) and `OverlapPolicy` (skip/queue) settings in
Settings under a new Queue group.
- **Parallel mode** (default): all due jobs start simultaneously.
- **Sequential mode**: due jobs run one at a time, in order.
- **Skip policy** (default): if a job comes due while its previous run is still active, the new
run is discarded.
- **Queue policy**: if a job comes due while running, the run is held and automatically started
when the current run completes.
- Both settings are persisted to `gosentry.json` and validated on load; defaults ensure
backward compatibility.
**Notifications and command input:**
- Failed job runs now raise a desktop notification (when enabled in Settings) with the job name
and failure detail. Notifications fire for scheduled and manual runs; internal activity events
are not notified.
- Added a Browse button next to the Command field in the job dialog for file picker selection.
**UI and platform integration:**
- Removed all PySentry legacy code: registry autostart entries (Windows), systemd and desktop
file cleanup (Linux).
- Updated `.gitignore` and `.dockerignore` to track `gosentry.json` / `jobs.json` instead of
legacy YAML filenames; added `*.yaml` wildcard to ignore legacy files during import.
- Moved developer documentation (Requirements, Build, Run From Source, Project Layout, Dependencies)
out of README into `docs/DEVELOPMENT.md`. README now focuses on end-user content.
**Icons and tray:**
- Regenerated all icon assets with feathered color-to-alpha so the rounded-tile boundary is
transparent — the opaque white halo visible on dark taskbars and trays is gone.
- Rebuilt `gosentry.ico` as a multi-size file (16 hand-tuned + 32/48/256 from the large PNG)
and added a dedicated 16×16 icon for the Windows tray.
- Per-platform icon wiring: Windows window/taskbar uses the ICO resource so GLFW selects the
right frame per slot; Windows tray uses the 16×16 ICO; Linux titlebar uses `IconSmall()` for
a crisp ~16 px `_NET_WM_ICON`.
- Left-clicking the tray icon now shows and focuses the main window without opening the menu;
the explicit "Show" menu item is preserved for right-click access.
**Performance:**
- Upgraded Fyne 2.6.3 → 2.7.4 (systray 1.11.0 → 1.12.1): startup time drops from ~644 ms to
~414 ms (36%).
- Moved Windows-only runner tests into `runner_windows_test.go` (guarded by `//go:build windows`)
to fix Linux test build.
## 0.8.0 - 2026-06-23
**Desktop notifications for failed jobs; Browse button for command paths.**
- Failed job runs now raise a desktop notification (title "GoSentry: Job Failed", body shows the job name and failure detail) when the "Show desktop notifications for failed jobs" setting is enabled. Notifications fire for scheduled and manual runs only; internal activity events are not notified.
- Added a Browse button next to the Command field in the job dialog so users can pick an executable from a file picker instead of typing the full path.
## 0.7.0 - 2026-06-23
**Execution modes and overlap policies for parallel and sequential job dispatch.**
- Added `ExecutionMode` setting (parallel/sequential) and `OverlapPolicy` setting (skip/queue) in Settings under a new Queue group.
- **Parallel mode** (default): all due jobs start simultaneously.
- **Sequential mode**: due jobs run one at a time, in order; a new job waits for the previous one to finish.
- **Skip policy** (default): if a job comes due again while its previous run is still active, the new run is discarded.
- **Queue policy**: if a job comes due again while running, the run is held and automatically started when the current run completes.
- Both settings are persisted to `gosentry.json` and validated on load; defaults ensure backward compatibility with existing installations.
- Added comprehensive unit tests verifying parallel start, sequential serialization, skip drops, and queue re-runs.
- Manual runs (`RunNow`) respect sequential mode: refused while any other job is running.
- No observable behavior changes with default (parallel/skip) settings; installations upgrading from earlier versions continue unchanged.
## 0.6.0 - 2026-06-22
**PySentry legacy code removed.**
- Removed all PySentry registry autostart entries (Windows), systemd and desktop file cleanup (Linux), and associated legacy code paths.
- Updated `.gitignore` and `.dockerignore` to ignore `gosentry.json` / `jobs.json` instead of the old YAML filenames;
added `*.yaml` wildcard to ignore legacy files during the import window.
- No observable behavior changes; codebase cleanup after migration from PySentry naming.
## 0.5.0 - 2026-06-22
**Storage migrated from YAML to JSON; exit-code flexibility removed.**
- Settings and jobs are now stored as `gosentry.json` and `jobs.json` (2-space indented JSON).
On first run after upgrading, existing `gosentry.yaml` / `jobs.yaml` files are imported
automatically and the JSON files are written; the YAML files are not deleted and can be
removed manually.
- Removed `SuccessExitCodes` field from jobs. Exit-code handling is now fixed: exit code 0 is
success, any nonzero exit code is failure. Jobs that relied on nonzero success codes will now
show "Failed"; update those jobs to use `StartOnly` mode if the exit code is irrelevant.
- Deleted `runner/exitcodes.go`; simplified `runStateDetail` accordingly.
- Tests updated: JSON round-trip tests, YAML-import tests for both config and jobs,
exit-code flexibility tests removed.
## 0.4.0 - 2026-06-22
**Architectural milestone: completed refactoring and reached target architecture.**
- Completed Phase 5 refactoring: hardening, testing, and documentation.
- Surface all save/cleanup errors from service and storage; no more silently swallowed `_ = ...` on persistence.
- Introduced `platform/autostart.Manager` interface with per-platform implementations (Windows, Linux, other); inject into service instead of calling package functions.
- Filled test gaps: folder filtering, log cleanup (count and age), settings persistence and migration, concurrent run prevention.
- Verified `go test -race ./...` passes on Windows; no data races in the refactored codebase.
- Updated `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and README with final package structure and build/test instructions.
- **Refactoring target reached:** Service layer owns all state and is the sole writer; UI is a thin view marshaling updates via `fyne.Do`; core engines are stateless and injectable; domain layer is pure with no test noise.
- Known follow-ups recorded in `ROADMAP.md`:
- Linux test build is currently broken (Windows-only test symbols need `//go:build windows`); will fix separately.
- File-size soft limits exceeded in a few places; revisit when next editing those files.
- No observable behavior changes.
## 0.3.6 - 2026-06-22
- Completed Phase 4 refactoring: carved up the GUI into focused, testable components.
- Renamed `src/gui``src/ui` and split monolithic `app.go` into `run.go` (lifecycle) and `mainwindow.go` (view construction).
- Extracted view components into separate files: `jobs_view.go`, `job_dialog.go`, `history_view.go`, `settings_view.go`.
- Extracted platform wiring into separate files: `tray.go`, `singleinstance.go`, `layout.go`.
- Removed forbidden platform imports (autostart, desktop, paths) from `src/ui`; all platform concerns now flow through `app.Service`.
- Upgraded Fyne from v2.5.3 to v2.6.3 to enable `fyne.Do` for cross-thread widget marshaling (resolves concurrency issue #4).
- Added `docs/PERFORMANCE.md` with measured startup-time analysis: the ~290ms increase from Phase 4 is entirely the Fyne 2.6.3 upgrade's `w.Show()` cost, not the restructuring.
- Added `docs/PERFORMANCE.md` and wired post-Fyne-2.7.x re-check into `ROADMAP.md`.
- No observable behavior changes; continued internal refactoring toward separated concerns and testability.
## 0.3.5 - 2026-06-19
- Completed Phase 3 refactoring: application service and state management.
- Added `app.Service` as the single owner of application state (job registry, settings, run history).
- Implemented event-driven observer dispatch: Services can emit events (JobAdded, JobChanged, etc.) to decouple state changes from UI updates.
- Added `app.Clock` interface for testable time-dependent behavior in scheduler and run tracking.
- Converted scheduler to drive app.Service instead of directly managing domain state.
- Created `app.Format` helpers for display rendering (job names, schedule summaries, run times).
- Added comprehensive unit tests for app.Service and supporting types.
- No observable behavior changes; continued internal refactoring toward separated concerns and testability.
## 0.3.4 - 2026-06-19
- Completed Phase 2 refactoring: domain cleanup and value object extraction.
- Split durable job configuration (`domain.Job`) from transient execution state (`domain.JobRuntime`), keyed by job ID.
- Added `domain.Schedule` value object with `Parse`, `Validate`, and `Next(time.Time)` methods for cron/interval parsing.
- Migrated scheduler to parse schedules once at load/edit instead of per tick, removing duplicated parsing.
- Made `RunJob` pure: runner no longer mutates jobs, returning only `RunRecord` for the caller to fold into runtime state.
- Simplified `storage.normalizeJobs` to touch only durable configuration; runtime initialization moved to `domain.NewRuntime`.
- No observable behavior changes; continued internal refactoring toward separated concerns.
## 0.3.3 - 2026-06-18
- Completed Phase 1 refactoring: split the flat `src/core` package into specialized, focused packages:
- `src/domain` for pure types (Job, RunRecord, Config)
- `src/storage` for persistence (Load/Save, Paths, YAML helpers)
- `src/runner` for job execution (RunJob orchestration, logging, exit codes)
- `src/scheduler` for timing loop
- `src/platform/winproc` for cross-platform hidden window configuration
- `src/platform/autostart` for system autostart integration
- `src/platform/desktop` for desktop environment integration
- `src/app` for application-level code (Version, future Service layer)
- No observable behavior changes; internal structure improvements only.
## 0.3.1 - 2026-06-17
- Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction.
+351
View File
@@ -0,0 +1,351 @@
# GoSentry — Development
Toolchain, dependency, build, and release information for contributors.
## Contents
1. [Technology Stack and Tools](#1-technology-stack-and-tools)
- [Toolchain — Windows](#toolchain--windows)
- [Toolchain — Linux](#toolchain--linux)
- [Repository scripts](#repository-scripts)
2. [External Libraries](#2-external-libraries)
3. [Run From Source](#3-run-from-source)
4. [Building the Executable](#4-building-the-executable)
- [Windows](#windows)
- [Linux](#linux)
- [Linux using Docker](#linux-using-docker)
5. [Building a Release](#5-building-a-release)
- [All targets from Linux](#all-targets-from-linux)
- [Packaging](#packaging)
6. [CI](#6-ci)
- [Cutting a release](#cutting-a-release)
- [Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)
## 1. Technology Stack and Tools
GoSentry is a single desktop process written in Go with a Fyne GUI. There is no
server component and no external runtime: the release artifact is one native
executable per platform.
| Layer | Choice |
| --- | --- |
| Language | Go 1.22 or newer |
| GUI toolkit | Fyne v2 (OpenGL desktop backend) |
| Scheduling | `robfig/cron/v3` expression parser |
| Persistence | Plain JSON files (`gosentry.json`, `jobs.json`) |
| Build | `go build` driven by the scripts in `scripts/` |
| Reproducible builds | Docker (`golang:1.22-bookworm` based [Dockerfile](../Dockerfile)) |
| CI | GitHub Actions and Forgejo Actions (Codeberg) |
CGO is mandatory. The Fyne desktop backend links against native OpenGL and
window-system libraries, so a C compiler must be present for every build,
including `go run` and `go test`.
### Toolchain — Windows
- [Go](https://go.dev/) 1.22 or newer.
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin` (plus `windres` for the icon
resource).
```powershell
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
# The default installer path is C:\Program Files\Go.
go version
# 2. Install MSYS2 from https://www.msys2.org/.
# Use the default installation path so UCRT64 tools are placed under
# C:\msys64\ucrt64\bin.
# 3. Open "MSYS2 UCRT64" from the Start menu and install GCC plus windres.
pacman -Syu
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-binutils
# 4. In PowerShell, check that the compiler is available where the build script
# expects it. build-windows.bat prepends this directory automatically.
Test-Path C:\msys64\ucrt64\bin\gcc.exe
Test-Path C:\msys64\ucrt64\bin\windres.exe
```
### Toolchain — Linux
- [Go](https://go.dev/) 1.22 or newer.
- A C compiler.
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11
development packages.
```bash
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
# development packages provide the native desktop headers used by Fyne.
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
```
### Repository scripts
| Script | Purpose |
| --- | --- |
| `scripts/test.bat`, `scripts/test.sh` | `go vet ./...` then `go test -race ./...` |
| `scripts/build-windows.bat` | Windows amd64 executable |
| `scripts/build-linux.sh` | Linux amd64 executable |
| `scripts/build-linux-docker.sh` | Linux amd64 executable, built in Docker |
| `scripts/build-release-linux.sh` | Multi-target release artifacts from one Linux/Docker workflow |
| `scripts/package-windows.bat`, `scripts/package-linux.sh` | Wrap a built binary into a distributable archive |
| `scripts/ci-build-release.sh` | Entry point used by both CI workflows |
Build outputs are written to `dist/`. The package layout is documented in
[ARCHITECTURE.md](ARCHITECTURE.md).
## 2. External Libraries
GoSentry keeps the direct dependency list intentionally small. GoSentry itself
is distributed under the [MIT License](../LICENSE).
| Dependency | Version | Repository | License |
| --- | --- | --- | --- |
| Go toolchain | 1.22+ | https://go.googlesource.com/go | BSD 3-Clause |
| `fyne.io/fyne/v2` | v2.7.4 | https://github.com/fyne-io/fyne | BSD 3-Clause |
| `github.com/robfig/cron/v3` | v3.0.1 | https://github.com/robfig/cron | MIT |
The remaining entries in `go.mod` are indirect dependencies pulled in by Fyne
and the Go module resolver. To list every direct and indirect module used by the
current checkout:
```bash
go list -m all
```
## 3. Run From Source
Windows:
```powershell
# Fyne requires CGO on Windows. MSYS2 UCRT64 provides the C compiler and native
# libraries used by the desktop backend.
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
$env:CGO_ENABLED = '1'
# go run starts the app from source. Use scripts\build-windows.bat when you need
# a standalone .exe without a console window.
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/gosentry
```
Linux:
```bash
# CGO must stay enabled because the Fyne GUI links against native Linux desktop
# libraries.
CGO_ENABLED=1 go run ./cmd/gosentry
```
The same environment is required for the test suite — see
[TESTS.md](TESTS.md):
```powershell
scripts\test.bat
```
## 4. Building the Executable
### Windows
```powershell
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
# to the repository root first, so double-clicking it from Explorer works. It
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
# when windres is available, and uses the Windows GUI subsystem so no console
# window opens at startup.
.\scripts\build-windows.bat
```
The Windows build is created as a GUI application, so it does not open a
terminal window. The binary is written to:
```text
dist\windows\gosentry-<version>-windows-amd64.exe
```
### Linux
```bash
# Make the helper executable once, then build a linux/amd64 Fyne binary.
chmod +x ./scripts/build-linux.sh
./scripts/build-linux.sh
```
The binary is written to:
```text
dist/linux/gosentry-<version>-linux-amd64
```
### Linux using Docker
```bash
# Builds the Linux binary inside Docker using the versioned image tag
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
# where the native Linux/Fyne packages are not installed locally.
chmod +x ./scripts/build-linux-docker.sh
./scripts/build-linux-docker.sh
```
The binary is copied to:
```text
dist/linux/gosentry-<version>-linux-amd64
```
## 5. Building a Release
### All targets from Linux
```bash
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
# from one Linux/Docker workflow. The Dockerfile contains the builder
# environment; the build commands live in this script. Docker runs the build
# with the current user's UID/GID so dist/ files are not owned by root.
chmod +x ./scripts/build-release-linux.sh
./scripts/build-release-linux.sh
```
Non-interactive release builds can pass target names:
```bash
# Build only Linux arm64 and Windows amd64 artifacts.
./scripts/build-release-linux.sh linux-arm64 windows-amd64
```
The binaries are copied to:
```text
dist/linux/gosentry-<version>-linux-amd64
dist/linux/gosentry-<version>-linux-arm64
dist/windows/gosentry-<version>-windows-amd64.exe
```
### Packaging
The `package-*` scripts build the binary for their platform and wrap it in a
distributable archive together with `README.md` and `CHANGELOG.md`:
Windows:
```powershell
scripts\package-windows.bat
```
```text
dist\windows\gosentry-<version>-windows-amd64.zip
```
Linux:
```bash
./scripts/package-linux.sh
```
```text
dist/linux/gosentry-<version>-linux-amd64.tar.gz
dist/linux/gosentry-<version>-linux-arm64.tar.gz
```
The arm64 archive is produced only when the `aarch64-linux-gnu-gcc` cross
compiler is available; otherwise that target is skipped with a message.
The version stamped into the file names and into the binary comes from
`src/app/version.go`.
## 6. CI
Tagged releases are built automatically on both GitHub and Codeberg:
- `.github/workflows/release.yml` — GitHub Actions.
- `.forgejo/workflows/release.yml` — Forgejo Actions (Codeberg).
Both run inside `golang:1.22-bookworm` (the same base image as the
[Dockerfile](../Dockerfile)), install the cross toolchain, and call
`scripts/ci-build-release.sh`, which builds and packages all three artifacts:
```text
dist/linux/gosentry-<version>-linux-amd64.tar.gz
dist/linux/gosentry-<version>-linux-arm64.tar.gz
dist/windows/gosentry-<version>-windows-amd64.zip
```
The Windows binary is cross-compiled with MinGW-w64 from the Linux job, so no
Windows runner is required. Each archive contains the executable plus `README.md`
and `CHANGELOG.md`, matching the local `package-*` scripts.
### Cutting a release
Before tagging:
1. Bump `src/app/version.go`. The tag must match it exactly.
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
easy to forget because nothing fails without it: `README.md` is packaged
inside every release archive and is what the forge shows on the project page,
so a stale shot advertises an application that no longer exists. Take them
from a real build, not from a development run with test data.
4. Run `scripts/test.bat` (or `go vet ./... && go test -race ./...`) and push
`main`, so the tag lands on a commit the forge actually has.
Then create and publish a release with a matching `v` tag on the forge (GitHub
Releases / Codeberg releases). `origin` is the Gitea repository, and GitHub is a
push mirror of it, so the tag is pushed to Gitea and reaches GitHub through the
mirror — never created on GitHub directly (see
[Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)):
```bash
git tag v0.11.5
git push origin v0.11.5 # Gitea; and to the Codeberg remote
# wait for the mirror, then confirm GitHub actually has the tag
git ls-remote --tags https://github.com/mixeme/gosentry.git v0.11.5
gh release create v0.11.5 --verify-tag --generate-notes # GitHub; publishes the release
```
Publishing the release triggers the workflow: it strips the leading `v` from
the tag and injects it as the version (so the tag must match `version.go`),
builds the archives, and attaches them to that release. `workflow_dispatch`
also allows a manual, upload-free build to smoke-test the pipeline.
Codeberg publishing needs a repository secret named `RELEASE_TOKEN` (a Codeberg
access token with the `write:repository` scope) under
**Settings → Actions → Secrets**. Without it the build and packaging steps still
succeed, but the upload step fails on authentication and takes the job down with
it, leaving a published release with no attached assets. GitHub needs no such
setup: `softprops/action-gh-release` falls back to the built-in `GITHUB_TOKEN`,
and the workflow already grants it `contents: write`.
### Releasing through the GitHub push mirror
The GitHub repository `mixeme/gosentry` is not a separate remote you push to; it
is a push mirror driven by Gitea. Gitea mirrors with pruning, so every ref that
exists on GitHub but not in Gitea is deleted on the next synchronisation.
This is what breaks the obvious way of cutting a GitHub release. `gh release
create v1.0.0` creates the tag on GitHub when it is missing — a tag Gitea has
never heard of. The next mirror run prunes it, GitHub orphans the release whose
tag disappeared and turns it into a draft, and the release looks deleted on the
Releases page. The archives go with it. Nothing reports an error: the workflow
ran, the assets uploaded, and the release evaporated afterwards.
The order that works is therefore:
1. `git push origin <tag>` — the tag enters Gitea, which owns it.
2. Wait for the mirror, or force it with **Settings → Repository → Mirror
Settings → Synchronize Now** in Gitea.
3. `git ls-remote --tags https://github.com/mixeme/gosentry.git <tag>` — confirm
GitHub has it.
4. `gh release create <tag> --verify-tag …``--verify-tag` is the guard, not a
nicety: without it `gh` silently creates the doomed tag when the mirror has
not caught up yet.
Release notes and assets are GitHub-side metadata; a mirror push cannot touch
them, so once the release sits on a mirrored tag, later synchronisations leave
it alone. Two consequences follow. Moving a published tag in Gitea force-pushes
it on GitHub and leaves the release pointing at a different commit, and deleting
a published tag in Gitea destroys the GitHub release along with its uploaded
archives — neither is recoverable from the mirror side. Codeberg is unaffected:
its releases live in the same forge as its tags.
+79
View File
@@ -0,0 +1,79 @@
# Performance Notes
Measured performance findings for GoSentry. Each entry records the method so the
numbers can be reproduced and re-checked after relevant changes.
## Startup Time
### Finding (2026-06-22)
After the Phase 4 refactor, cold startup time (the History "Window shown in …"
metric) increased by **~290 ms**. The increase is caused entirely by the
**Fyne v2.5.3 → v2.6.3 upgrade** (task T4.1), **not** by the Service / domain /
UI restructuring.
### Method
Env-gated phase timers (`GOSENTRY_TIMING`) were added across the startup path
(`Run` in `src/ui/run.go` and `newMainView` in `src/ui/mainwindow.go`) and the
equivalent points in the pre-refactor entry point (`src/gui/app.go` at commit
`c5e0ef9`, the last commit before T4.1). Both were built with the CGO / MSYS2
UCRT64 toolchain and run 5× each; the first run of each is a cold-disk outlier
and is excluded. The timed span (`started``w.Show()`) is identical in both
builds, so the comparison is fair.
### Results (warm-run averages)
| Phase (cumulative from start) | Old (Fyne 2.5.3) | New (Fyne 2.6.3) | Δ |
|-------------------------------|------------------|------------------|--------|
| after single-instance check | ~0.5 ms | ~0.6 ms | — |
| after Fyne app + window + tray | ~277 ms | ~285 ms | +8 ms |
| `app.Open()` done | +3 ms | +3 ms | 0 |
| views built + `svc.Start()` | +42 ms | +43 ms | ~0 |
| after `SetContent` | ~348 ms | ~353 ms | +5 ms |
| **after `w.Show()` (TOTAL)** | **~348 ms** | **~644 ms** | **+~290 ms** |
### Interpretation
- Everything up to and including `SetContent` costs the same in both versions
(~350 ms). The refactor-specific code — `app.Open()` (~3 ms) and the new
`app.Service` plus view construction (~42 ms) — is unchanged, so the
restructuring added no measurable startup cost.
- The entire regression lands in **`w.Show()`**: ~0 ms under Fyne 2.5.3,
~290 ms under 2.6.3. Fyne 2.6 reworked main-thread marshaling (the change that
introduced `fyne.Do`) and front-loads first-window GL/driver realization into
the `Show()` call.
- The cost is a fixed, one-time Fyne expense, not a leak in GoSentry code, and
the upgrade cannot be reverted because `fyne.Do` requires Fyne ≥ 2.6.
- The tray / autostart path (`--start-in-tray`) skips `w.Show()` until the user
opens the window, so it is unaffected.
### Finding (2026-06-23, Fyne 2.7.4)
**Fyne upgraded from v2.6.3 → v2.7.4** as part of Phase 6 (P6.1).
### Method
Same `GOSENTRY_TIMING` env-gated phase timers as the 2026-06-22 baseline:
`fmt.Fprintf(os.Stderr, ...)` checkpoints added to `src/ui/run.go` across the
startup path, built with the CGO / MSYS2 UCRT64 toolchain and run 5×; run 1
(cold disk) is excluded. Timers were reverted before committing.
### Results (warm-run averages, runs 25)
| Phase (cumulative from start) | Fyne 2.6.3 | Fyne 2.7.4 | Δ |
|-------------------------------|------------|------------|---------|
| after single-instance check | ~0.5 ms | ~1 ms | — |
| after Fyne app + window + tray | ~285 ms | ~193 ms | 92 ms |
| after newMainView | ~328 ms | ~249 ms | 79 ms |
| after SetContent | ~353 ms | ~252 ms | 101 ms |
| **after w.Show() [TOTAL]** | **~644 ms**| **~414 ms**| **230 ms** |
### Interpretation
Fyne 2.7.4 is **~230 ms faster** (~36%) than Fyne 2.6.3 end-to-end. The gain is
spread evenly: Fyne app + window + tray is 92 ms faster, `w.Show()` itself is
~161 ms faster. This confirms Fyne 2.7.0's "Massive performance increases on
rendering" and driver/threading improvements are real and material for GoSentry.
The upgrade is a net win: `fyne.Do` threading (required since 2.6) is retained
and the startup cost is substantially reduced.
+86
View File
@@ -0,0 +1,86 @@
# GoSentry — Review Agenda
What to look at when reviewing the project as a whole, as opposed to a single
diff. This is the agenda; the rules a review checks against live in
[STANDARDS.md](STANDARDS.md) and [ARCHITECTURE.md](ARCHITECTURE.md).
Scope note: a normal pull-request review checks the change. This agenda is for
a periodic sweep of the whole codebase, so a pass may legitimately end with
"nothing to report" on most items.
## 1. Architecture and project structure
Does the code still match the package map and the event flow in
[ARCHITECTURE.md](ARCHITECTURE.md)? Watch for the boundaries that matter here:
`app.Service` as the sole owner of job and runtime state, the UI reading it
through typed events, `domain` staying free of I/O, and platform-specific code
staying behind the `platform/*` interfaces.
## 2. Complexity against the size of the project
GoSentry is a single-process desktop app with two direct dependencies. Flag
abstraction that is not paying for itself: interfaces with one implementation
and no test seam, indirection added for a use case nobody has asked for, a new
dependency where thirty lines of standard library would do. Also check the
opposite direction — files that have grown past the size guideline in
ARCHITECTURE and should be split the way `jobs_view.go` was.
## 3. Code quality
The checkable rules are in [STANDARDS.md](STANDARDS.md) — error handling, unit
tests for pure helpers, regression tests for fixes, `fyne.Do` for updates off
the main thread. Beyond them: concurrency around `Service.mu`, goroutines whose
lifetime is not obvious, and error paths that report something less useful than
what they caught.
## 4. Documentation and comments
Does every documented behavior still exist, and does every non-obvious behavior
get documented? Check the doc set against the code: README (user-facing
behavior and config keys), ARCHITECTURE (packages and flows), STANDARDS
(rules and intentional behavior), DEVELOPMENT (build), TESTS, PERFORMANCE,
CHANGELOG (an entry per notable change). For comments, the bar is *why*, not
*what* — a comment restating the line below it is noise; an unexplained
workaround is a finding.
## 5. Readability and maintainability
Read a package as someone who has not seen it before. Can the next change be
made without reverse-engineering? Naming that matches the domain vocabulary,
functions that do one thing, and control flow that does not need a diagram.
## 6. Logical errors
Correctness independent of style: scheduling and timing edge cases (overlap
policy, sequential mode, pause interactions), off-by-one and boundary handling,
zero values that mean something (see the timeout rules in STANDARDS), state
that can be observed mid-update, and error paths that leave state inconsistent.
## 7. Legacy code and migrations
The app has no database, so migration means file compatibility: `gosentry.json`
and `jobs.json` written by an older version must keep working. Check that new
`Config` fields are backward compatible, that normalization happens in one
place, and that values which are meaningful zeros are not normalized away. Also
look for code kept alive only for a case that no longer exists.
## 8. Undocumented or under-documented contentious decisions
Any decision a future reader would question needs its reasoning recorded where
it lives: a comment at the code, an entry in the "Intentional behavior" section
of [STANDARDS.md](STANDARDS.md), or — when the work is deferred rather than
decided — a note in [ROADMAP.md](ROADMAP.md), which is where the frozen
window-size work keeps its rationale.
## 9. Other improvement proposals
Anything that does not fit above: build and release ergonomics, test coverage
gaps, dependency health, UX rough edges.
## What happens to the findings
- A defect → fix it, with a regression test when severity is medium or higher.
- Behavior that turns out to be deliberate → record it under "Intentional
behavior" in [STANDARDS.md](STANDARDS.md) so it is not re-reported.
- Work larger than a single fix → [ROADMAP.md](ROADMAP.md), with the reasoning.
- A new rule the review establishes → [STANDARDS.md](STANDARDS.md).
+149 -45
View File
@@ -1,63 +1,167 @@
# Roadmap
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.
## Post-Field-Test Cleanup
## Open Items
After real-world use confirms the main workflows, clean up temporary
stabilization code and development scaffolding.
### Update check from GitHub releases
Cleanup checklist:
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
`.github/workflows/release.yml`), but the app never tells the user a newer
version exists — they have to check the releases page by hand.
- Review and remove debug-oriented diagnostics that are no longer useful.
- Remove excessive defensive checks once behavior is proven and covered by the
right tests.
- Remove obsolete compatibility cleanup, such as old autostart migration code,
after the transition window is over.
- Delete stale generated files and old build artifacts from local/release flows.
- Revisit tests and remove ones that only lock in temporary implementation
details instead of real user-facing behavior.
- Simplify README notes that were useful during early setup but are too noisy
for normal users.
- Recheck `.gitignore`, Docker scripts, and packaging scripts for rules or
branches that only supported early experiments.
Add an update check that queries the GitHub Releases API
(`GET /repos/mixeme/gosentry/releases/latest`) for the latest published tag,
strips the leading `v`, and compares it against `app.Version`. When a newer
version is available, surface it non-intrusively — an "Update available"
line in Settings (next to the existing version/build info) with a hyperlink
to the release page, not a modal on launch.
## Tray Interaction
Design notes / open questions:
Improve tray icon interaction after choosing a tray backend path.
- *Opt-in and offline-safe.* The check makes a network request, so it must be
off by default (or clearly consented) and never block startup. Failures
(offline, rate-limited, API change) should be silent — no error dialogs for a
best-effort convenience feature.
- *Version comparison.* Compare semantic versions, not strings, so `0.12.0`
reads as newer than `0.9.0`. A tiny semver comparator in `app` (or a small
dependency) avoids lexical bugs.
- *Where the check lives.* Keep it in the `app` layer behind the Service so the
UI only renders the result, and cache the last check so opening Settings
repeatedly does not spam the API (unauthenticated GitHub allows 60 req/h).
- *Repo coordinates.* The primary remote is Gitea; the GitHub repo used for
releases is [`mixeme/gosentry`](https://github.com/mixeme/gosentry) and must
be wired in explicitly (constant or build-time value) rather than derived from
`origin`.
- *No auto-download.* Scope is detection and notification only; installing the
update stays a manual click-through to the release page.
- Add double-click on the tray icon to show and focus the main window.
- Current Fyne 2.5.3 desktop tray API exposes menu and icon setup, but does not
expose click or double-click callbacks for the tray icon itself.
- Revisit when Fyne exposes this callback, or evaluate a small platform-specific
tray integration if the behavior becomes important enough.
### Import/export jobs as a cron table
## Delivery And Packaging
Jobs can only be moved between machines by copying `jobs.json` by hand. Add
"Import" / "Export" actions (Settings tab, file dialogs) that read and write a
crontab-style text file, so a job list can be shared, version-controlled, or
seeded from an existing Unix crontab.
Keep a single portable binary as the baseline delivery format. It is simple to
test, easy to copy between machines, and matches the current storage model where
runtime YAML files live next to the executable by default.
Export writes one line per job — schedule fields, then command and arguments —
and import parses the same format back into `domain.Job` values.
Planned delivery variants:
Design notes / open questions:
- Windows portable `.zip` with `gosentry.exe`, `README.md`, and `CHANGELOG.md`.
- Linux portable `.tar.gz` archives for `linux-amd64` and `linux-arm64`.
- Debian/Ubuntu `.deb` package once the Linux runtime paths are settled.
- Windows installer later, likely Inno Setup first and MSI/WiX only if needed.
- AppImage as a possible Linux GUI-friendly format after the core workflow is stable.
- Flatpak only after the desktop integration story is clearer.
- winget manifest after stable public Windows releases exist.
- *The job model is wider than a crontab line.* `Name`, `Folder`, `StartOnly`,
`OverlapPolicy`, `TimeoutSeconds`, and `Enabled` have no cron equivalent.
Either accept a lossy export (schedule + command only) or carry the extra
fields in a structured comment above each line (`# gosentry: name=… folder=…
timeout=…`), which keeps the file readable by real cron while making the
round-trip lossless. The comment form is preferred; decide the exact key set
before implementing.
- *Disabled jobs.* `Enabled: false` maps naturally to a commented-out line, but
then a disabled job is indistinguishable from a user's own comment unless the
`# gosentry:` marker is present. Pick one representation and document it.
- *`@every` is not crontab.* GoSentry accepts `@every 10s` (see
[`domain.Parse`](../src/domain/schedule.go)), which no cron implementation
understands. Exporting it produces a file that is not a valid crontab;
exporting it as an approximation would silently change the schedule. Keep the
raw string and flag the file as GoSentry-flavoured, rather than converting.
- *Command vs arguments.* Crontab has a single command string; GoSentry splits
`Command` and `Arguments`. Import must split the line the same way the runner
would (see `runner/invocation*.go`, which differs per OS), and export must
join them back without changing quoting.
- *What to skip on import.* Environment assignments (`SHELL=`, `PATH=`,
`MAILTO=`), six-field (seconds) crontabs, and `@reboot` are outside what
`domain.Parse` accepts. Skip them, and report which lines were skipped and
why — a partial import that silently drops rows is worse than a failed one.
- *Merge semantics.* Import must decide between replacing the job list and
appending to it, and must assign fresh IDs rather than trusting the file.
Appending with a confirmation dialog is the safer default; replacing needs an
explicit "this deletes N jobs" confirmation.
- *Where it lives.* Encoding/decoding is pure text handling and belongs in
`domain` (or a small `storage` codec) with unit tests over round-trips; the
Service exposes import/export operations; the UI only picks the file and
shows the outcome.
Packaging design note:
### Split the files that are over the size guideline
- Portable builds can keep settings and jobs next to the executable.
- Installer/package builds should move runtime data to per-user locations:
`%APPDATA%\GoSentry` on Windows, and XDG directories such as
`~/.config/gosentry` and `~/.local/share/gosentry` on Linux.
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
and records the `jobs_view.go` and `settings_view.go` splits as the worked
examples. Six non-test files are over it at 1.0.0, including both files that
were already split once:
Initial priority:
| File | Lines |
|------|-------|
| `src/app/operations.go` | 490 |
| `src/ui/jobs_view.go` | 355 |
| `src/app/run.go` | 287 |
| `src/ui/history_view.go` | 282 |
| `src/ui/settings_view.go` | 277 |
| `src/storage/store.go` | 265 |
1. Windows portable `.zip`.
2. Linux portable `.tar.gz` for amd64 and arm64.
3. Debian/Ubuntu `.deb`.
4. Windows installer.
This is deliberately deferred to the next whole-project review rather than done
piecemeal: [REVIEW.md](REVIEW.md) already asks item 2 to look for exactly this,
a split touches every reader of the file, and doing all six in one pass keeps
the seams consistent instead of settling them six different ways. Splitting is
also the kind of change that reads as pure movement while quietly dropping a
function, so it wants one careful pass, not six hurried ones.
Seams visible today, as a starting point rather than a decision:
- **`operations.go`** — the worst overage and the clearest split: the public
mutating operations (`CreateJob``UpdateSettings`), the `…Locked` state
helpers that only they call, and the pure validators and normalizers
(`normalizeJob`, `validateJob`, `hasFileName`, `validateConfig`) are three
distinct jobs already sitting in three consecutive blocks.
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
`historyColumnWidths`) are pure, already unit-tested, and independent of the
table they size.
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
split has to break that function up (list template, toolbar handlers,
assembly) rather than move whole functions. Larger judgement call than the
others.
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
alone rather than splitting for the sake of the number.
Scope note: the guideline is about source files. Test files are much larger and
that is fine — a table-driven test file grows with the cases it covers.
### Window size persistence *(frozen)*
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
maximized, which would corrupt the stored size on the next launch.
Re-enabling requires a cross-platform way to detect the maximized state before
saving. Fyne v2.x has no API for this; it needs per-OS native calls:
`IsZoomed` (Windows), `_NET_WM_STATE` (X11/Linux), `NSWindow.isZoomed`
(macOS). Unfreeze once that detection is in place.
**Disadvantages of a platform-specific approach:**
- *Three separate implementations.* Windows, macOS, and Linux each need their
own file guarded by a build tag. Each adds CGO bindings or raw syscall
wrappers that must be kept in sync as OS APIs evolve.
- *Linux is not one target.* X11 and Wayland have completely different window
state models. `_NET_WM_STATE` is X11-only; under Wayland the compositor
controls window decorations and there is no stable client-side API to query
the maximized state. A single `linux` build tag cannot cover both correctly.
- *Native window handle is not exposed.* Fyne does not surface the underlying
`HWND` / `NSWindow` / `XID` through its public API. Obtaining it requires
either enumerating OS-level windows by PID (fragile, finds wrong windows when
dialogs are open) or reaching into Fyne/GLFW internals (breaks on Fyne
upgrades).
- *Thread-safety constraints.* Win32 and GLFW both require their calls to be
made from the OS main thread. Tray-menu callbacks run on a separate goroutine,
so any native call must be marshalled back to the main thread, adding
synchronisation complexity.
- *Test coverage gap.* Maximized-state detection cannot be exercised by Fyne's
headless test driver; it requires a real display and manual or screen-capture
automation per platform.
### History tab — column filters (Trigger / Job / State)
Add dropdown filters above the History table so the user can narrow rows by
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
`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.
+74
View File
@@ -0,0 +1,74 @@
# GoSentry — Standards
Quality rules and intentional behavior for contributors. Package contracts live
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md);
what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
## Code quality
- Follow package contracts in [ARCHITECTURE.md](ARCHITECTURE.md).
- User-facing errors → `dialog.ShowError` or a History event, never a silent `return`.
- Pure helpers → unit test in the same package.
- Fixes with severity ≥ medium → regression test.
- Documented intentional behavior → section below, not a backlog bug.
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
- A size that must follow the theme is **measured at build time, not written as
a pixel constant.** `theme.Padding()` and text metrics depend on the running
app's theme, text size, and DPI, so a hand-tuned number is only correct for
the one theme it was tuned against and clips under any other. Measure the real
widget, or derive the value from the theme, in a named helper: `rowOverlap`
(theme padding), `captionColumnWidth` and `textColumnWidth` (the widest of the
actual strings), `activityRowsHeight` (the list's own row template). The same
applies to a ratio computed from an absolute width — see `initialSplitOffset`.
A raw pixel literal is left only where nothing about it tracks the theme, and
says so in a comment.
## Config file compatibility
There is no migration step: `gosentry.json` and `jobs.json` are read as-is, are
meant to be hand-editable, and may have been written by an older version. A
change to their shape has to stay compatible on its own.
- A new `Config` field is tagged `omitempty`, and its zero value must mean the
behavior that existed before the field was added — a file written without it
keeps working unchanged. `DefaultConfig()` still sets the value explicitly.
- A zero that carries meaning is not a missing field and must not be backfilled
on load. See `DefaultTimeoutSeconds` in `storage.loadOrCreateConfig` and
`Job.TimeoutSeconds *int`, where unset and `0` are different answers.
- An unrecognised enum value reads as the default rather than an error, through
one helper that every consumer shares (`JobListView.IsCompact`, `ui.themeFor`),
and is normalized before being written back, so the file never gains a value
no reader understands.
- A renamed key keeps the old field on `Config` (tagged `omitempty`) purely so
it can still be read. `storage.loadOrCreateConfig` converts it to the new
field and clears it, so the retired key disappears on the next save. See
`Config.JobsDir``Config.JobsFile`. Where the new field has a non-empty
default, clear that default before unmarshalling, or "the file omits it" and
"the file sets it" become indistinguishable and the conversion never runs.
- Each of the three gets a test: the default in `storage`, the normalization in
`domain`, and a round-trip through the real config file in `app`.
## Intentional behavior (not bugs)
- `RunNow` is allowed during global pause and for disabled jobs.
- Selecting a jobs file that already exists **loads** it: its jobs replace the
in-memory list, which is the only way the user can switch between job lists. A
path with no file behind it receives the current jobs (rename/relocate). The
switch is refused while a job is running, because adoption drops every runtime
and a finishing run would then write its result onto whichever job inherited
its ID.
- Sequential mode runs jobs FIFO by order in `jobs.json`.
- Scheduler tick is 1s — sub-second `@every` intervals are not supported.
- Command timeout defaults to no timeout globally (`Config.DefaultTimeoutSeconds`
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
global default, 0 = no timeout, positive = seconds). Neither zero may be
normalized away on load — 0 is a value, not a missing field.
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
current process. Log files on disk feed aggregate statistics via `SeedStats`
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
## Out of scope
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (update check from
GitHub releases, cron-table import/export, window size persistence, History
column filters).
+521 -131
View File
@@ -1,169 +1,559 @@
# GoSentry Test Suite
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
## Test Files Overview
### store_test.go
**Location:** `src/core/store_test.go`
**Package:** `core`
Tests YAML serialization and storage behavior.
| Test | Purpose |
|------|---------|
| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state fields (LastRun, NextRun, LastState, Output, etc.). Only job definitions are stored; runtime data is kept in memory and log files. |
---
### scheduler_test.go
**Location:** `src/core/scheduler_test.go`
**Package:** `core`
Tests schedule parsing and job invocation output formatting.
| Test | Purpose |
|------|---------|
| `TestNextRunTimeSupportsEvery` | Verifies `@every` duration syntax (e.g., `@every 10s`) correctly calculates next run time. Tests with 10-second interval. |
| `TestNextRunTimeSupportsCron` | Verifies standard 5-field cron expressions (e.g., `*/5 * * * *`) correctly calculate next run time. Tests 5-minute interval. |
| `TestRunningOutputIncludesInvocation` | Verifies the running job output header includes all relevant invocation details: command, arguments, success exit codes, start time, and trigger type. |
---
### runner_test.go
**Location:** `src/core/runner_test.go`
**Package:** `core`
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
#### Log File Tests
| Test | Purpose |
|------|---------|
| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with sanitized job name in filename and proper metadata (trigger type, job name, command output). |
#### Command Execution Tests
| Test | Platform | Purpose |
|------|----------|---------|
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes (e.g., `"C:\Program Files\..."`) are executed correctly via cmd.exe. |
| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces (e.g., `C:\Program Files\App\app.exe`) are quoted and executed correctly. |
| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments separated in the Job struct are combined and executed correctly. |
#### Exit Code Handling Tests
| Test | Purpose |
|------|---------|
| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes listed in `SuccessExitCodes` (e.g., `"0,1"`) result in "OK" status even if nonzero. Includes detail message about accepted exit code. |
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes not listed in `SuccessExitCodes` result in "Failed" status with exit code detail. |
#### Start-Only Mode Tests
| Test | Purpose |
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that jobs with `StartOnly: true` launch the process and return "OK" immediately without waiting for process exit or checking exit code. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that jobs with `StartOnly: true` still report "Failed" if the process fails to start (e.g., executable not found). |
#### Utility Function Tests
| Test | Platform | Purpose |
|------|----------|---------|
| `TestParseExitCodes` | All | Verifies that exit code strings with mixed separators (comma, semicolon, newline) are correctly parsed into integer slice. |
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands (with explicit path and arguments) do not request hidden window startup. |
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands (passed to cmd.exe) request hidden window startup to prevent console flash. |
| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies that shell commands use cmd.exe `/S /C` syntax with proper outer quoting to handle paths with spaces and special characters. |
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
---
### autostart_windows_test.go
**Location:** `src/core/autostart_windows_test.go`
**Package:** `core`
**Build Tags:** `//go:build windows` (Windows only)
Tests Windows autostart entry creation via shortcuts in the Startup folder.
| Test | Purpose |
|------|---------|
| `TestParseRegistryRunValue` | Verifies that legacy Windows Registry `Run` entry values are correctly parsed from `reg query` output (for migration/cleanup). |
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly (e.g., `"D:\..."` matches `d:\...`). |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison correctly matches paths with spaces both with and without quotes. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the startup shortcut path resolves to the user's Startup folder using `%APPDATA%` environment variable. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` shortcut files are created with correct `TargetPath` and `Arguments` (--start-in-tray) even when target path contains spaces. |
---
### autostart_linux_test.go
**Location:** `src/core/autostart_linux_test.go`
**Package:** `core`
**Build Tags:** `//go:build linux` (Linux only)
Tests Linux autostart entry creation via XDG Desktop Entry files.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with the `--start-in-tray` argument in the `Exec=` field, so scheduled jobs run immediately after login without displaying the window. |
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that legacy autostart entries (from old PySentry implementation) are cleaned up when enabling autostart through the new system. |
---
All tests are located alongside source code in their respective packages under `src/`. Tests follow Go conventions with `*_test.go` filename patterns.
## Running Tests
### Run all tests in the package
### Using the test scripts
The repository provides convenience scripts to run all tests with static analysis:
**Unix/Linux/macOS:**
```bash
cd D:\Local\Git\gosentry
go test ./src/core
./scripts/test.sh
```
### Run tests with verbose output
**Windows:**
```bash
go test -v ./src/core
scripts\test.bat
```
### Run specific test by name
Both scripts run:
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
2. `go test -race ./...` — tests with race condition detection enabled
The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
that means the MSYS2 UCRT64 toolchain described in
[DEVELOPMENT.md](DEVELOPMENT.md).
### Manual test commands
Run all tests:
```bash
go test -run TestRunJobWritesLogFile ./src/core
go test ./...
```
### Run Windows-only tests (on Windows)
Run all tests with race detection:
```bash
go test -v ./src/core # Windows build tags are active
go test -race ./...
```
### Run Linux-only tests (on Linux)
Run tests with verbose output:
```bash
go test -v ./src/core # Linux build tags are active
go test -v ./...
```
### Run with code coverage
Run a specific test by name:
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go test -run TestRunJobWritesLogFile ./src/runner
```
Run tests with code coverage:
```bash
go test -cover ./src/runner
go test -coverprofile=coverage.out ./src/runner
go tool cover -html=coverage.out
```
---
## Test Files Overview
### src/domain/schedule_test.go
**Package:** `domain`
Tests schedule parsing and validation.
| Test | Purpose |
|------|---------|
| `TestParseRejectsInvalidSchedules` | Verifies that invalid schedule strings return an error. |
| `TestParseEveryInterval` | Verifies `@every` duration syntax (e.g., `@every 10s`) is parsed and computes the correct next run time. |
| `TestParseEveryTrimsSurroundingWhitespace` | Verifies leading/trailing whitespace around the `@every` spec is ignored. |
| `TestParseCronExpression` | Verifies 5-field cron expressions (e.g., `*/5 * * * *`) are parsed and compute the correct next run time. |
| `TestParseCronDescriptor` | Verifies predefined cron descriptors such as `@hourly` are accepted. |
| `TestValidateAcceptsValidSchedules` | Verifies that `Schedule.Validate` returns nil for valid schedule strings. |
| `TestZeroScheduleNextIsZero` | Verifies that a zero-value Schedule returns a zero time from `Next`. |
| `TestStringReturnsTrimmedSpec` | Verifies that `Schedule.String` returns the trimmed schedule spec. |
---
### src/domain/config_test.go
**Package:** `domain`
Tests the normalization rule shared by every consumer of the jobs-list density
setting.
| Test | Purpose |
|------|---------|
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
---
### src/app/service_test.go
**Package:** `app`
Tests `Service` construction and the state-accessor contract.
| Test | Purpose |
|------|---------|
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. |
---
### src/app/operations_test.go
**Package:** `app`
Tests all mutating operations on the Service, scheduler integration, and settings persistence.
#### Job CRUD
| Test | Purpose |
|------|---------|
| `TestCreateJobAssignsIDAndEmits` | Verifies that `CreateJob` assigns a unique ID, persists to JSON, and emits `JobChanged`. |
| `TestCreateJobValidates` | Verifies that `CreateJob` rejects jobs with an invalid schedule. |
| `TestUpdateJobKeepsRuntimeAndReflectsDisable` | Verifies that `UpdateJob` preserves existing runtime state and disables a job correctly. |
| `TestUpdateJobReenablesPausedJob` | Verifies that re-enabling a previously-disabled job clears the paused runtime state. |
| `TestRuntimeLazilyRecreated` | Verifies that `UpdateJob` recreates a missing runtime entry rather than panicking. |
| `TestUpdateJobNotFound` | Verifies that `UpdateJob` returns an error for an unknown job ID. |
| `TestDeleteJobRemovesEverything` | Verifies that `DeleteJob` removes the job from the slice, the runtime map, and the schedule cache. |
| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. |
| `TestSetEnabledNotFound` | Verifies that `SetEnabled` returns an error for an unknown job ID. |
| `TestSetEnabledToggles` | Verifies that `SetEnabled` flips the enabled flag and persists the change. |
#### Global pause / run-now / run-due
| Test | Purpose |
|------|---------|
| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. |
| `TestSetGlobalPausePersistsToConfigFile` | Verifies the paused flag reaches `gosentry.json`, which is what makes the pause survive a restart. |
| `TestServiceRebuiltFromPausedStoreStartsPaused` | Verifies a Service built from a paused config starts paused, with the paused next-run text applied before the first tick. |
| `TestRunNowUsesRunnerAndRecords` | Verifies that `RunNow` invokes the runner, records a `RunRecord`, and emits `RunRecorded`. |
| `TestRunNowNotFound` | Verifies that `RunNow` returns an error for an unknown job ID. |
| `TestRunNowRefusedWhileAlreadyRunning` | Verifies that a second concurrent `RunNow` on the same job is rejected while the first is in progress. |
| `TestRunNowAllowedWhilePaused` | Verifies that `RunNow` is allowed when the global pause flag is set (pause stops scheduled runs only). |
| `TestRunDueStartsDueJob` | Verifies that `RunDue` launches a job whose next-run time has passed. |
| `TestRunDueSkipsJobNotYetDue` | Verifies that `RunDue` does not launch a job that is not yet due. |
| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job, even with a stale `NextDue` in the past. |
| `TestRunDueDoesNothingWhilePaused` | Verifies that `RunDue` launches nothing when the global pause flag is set. |
| `TestStartDrivesRunDueOnTick` | Verifies that `Service.Start` wires `RunDue` to the scheduler tick and that each tick advances state. |
#### Settings
| Test | Purpose |
|------|---------|
| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. |
| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. |
| `TestHasFileName` | Verifies the jobs-file path check: a file name passes; a trailing separator, `.`, and `..` do not. |
| `TestUpdateSettingsWritesJobsToTheNewFile` | Verifies that changing `JobsFile` re-resolves `Paths.JobsPath` and writes the loaded jobs to the new file, creating its folder. |
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
---
### src/app/run_test.go
**Package:** `app`
Tests overlap policy, sequential execution, run statistics, timeout resolution,
and scheduler edge cases using injected `runJob` and `primeDue`.
| Test | Purpose |
|------|---------|
| `TestUpdateStats` | Verifies aggregate duration math on `JobRuntime`. |
| `TestUpdateStatsSkipsZeroDuration` | Verifies zero-duration runs are excluded from averages. |
| `TestRunDueParallelStartsAllDueJobs` | Parallel mode: both due jobs enter the runner before either completes. |
| `TestRunDueSequentialSerializes` | Sequential mode: job 2 waits until job 1 finishes. |
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish. |
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
| `TestRunDueEmptyOverlapInheritsGlobal` | Empty per-job policy inherits the global default. |
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
---
### src/app/events_test.go
**Package:** `app`
Tests the event-emission and observer-subscription machinery.
| Test | Purpose |
|------|---------|
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
| `TestEmitWithNoObserversIsNoop` | Verifies that emitting an event with no observers does not panic. |
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
---
### src/app/format_test.go
**Package:** `app`
Tests display-formatting helpers used by the UI.
| Test | Purpose |
|------|---------|
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
| `TestDisplayInvocation` | Verifies that the full invocation display string combines command and arguments with spacing. |
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
| `TestDisplayStats` | Verifies statistics line formatting for the details panel. |
| `TestDisplayOverlapPolicy` | Verifies per-job vs inherited global overlap policy labels. |
| `TestDisplayTimeout` | Verifies the three timeout states read differently in the details panel: `45 s`, `no timeout`, and `… (global default)`. |
---
### src/storage/store_test.go
**Package:** `storage`
Tests JSON round-tripping, default generation, and backward compatibility.
| Test | Purpose |
|------|---------|
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
| `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. |
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
---
### src/scheduler/scheduler_test.go
**Package:** `scheduler`
Tests the timing-loop contract using a fake clock.
| Test | Purpose |
|------|---------|
| `TestSchedulerCallsTickWithClockNow` | Verifies that the scheduler calls the injected tick function with the wall-clock time returned by the fake Clock. |
| `TestSchedulerStopReleasesClock` | Verifies that `Stop` terminates the tick loop and releases the Clock without hanging. |
---
### src/runner/runner_test.go
**Package:** `runner`
Tests command execution, exit code handling, output capture, and the run timeout.
#### Log file tests
| Test | Purpose |
|------|---------|
| `TestRunJobLogFileAllHeaders` | Verifies that the log file contains all expected metadata headers: trigger type, job name, command, arguments, and start time. |
| `TestRunJobRecordFields` | Verifies that the returned `RunRecord` carries the correct status, trigger, and log-file path. |
| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with a sanitized job name in the filename. |
#### Output formatting
| Test | Purpose |
|------|---------|
| `TestFormatOutput` | Verifies that stdout and stderr are combined with section labels in the formatted output. |
| `TestFormatOutputEmptyStreams` | Verifies that empty stdout/stderr streams are omitted from the formatted output. |
| `TestLogArguments` | Verifies that arguments are included in the log header and absent when the arguments field is empty. |
| `TestSanitizeFileName` | Verifies that spaces and special characters in a job name are replaced to produce a safe filename segment. |
#### Command execution
| Test | Platform | Purpose |
|------|----------|---------|
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes are executed correctly via `cmd.exe`. |
| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces are quoted and executed correctly. |
| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments from the Job struct are combined and executed correctly. |
#### Exit code handling
| Test | Purpose |
|------|---------|
| `TestRunJobFailsOnNonZeroExitCode` | Verifies that a nonzero process exit code results in "Failed" status with an "exit code N" detail. |
#### Timeout
| Test | Purpose |
|------|---------|
| `TestRunJobTimesOut` | Verifies that a positive timeout kills a long-running command and reports `Timed out after <timeout>`. |
| `TestRunJobZeroTimeoutMeansNoTimeout` | Verifies that a non-positive duration runs without a deadline, bounded only by the caller's context. |
| `TestRunJobStartOnlyIgnoresTimeout` | Verifies that fire-and-forget jobs run on the untimed context, so the timeout never kills a process the runner is not waiting for. |
#### Start-only mode
| Test | Purpose |
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
---
### src/runner/runner_windows_test.go
**Location:** `src/runner/runner_windows_test.go`
**Build Tags:** `//go:build windows`
Tests the Windows shell invocation and hidden-window flags.
| Test | Purpose |
|------|---------|
| `TestDirectCommandDoesNotHideWindow` | Verifies that direct executable commands do not request hidden-window startup. |
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
---
### src/runner/seed_test.go
**Package:** `runner`
Tests `SeedStats`, which rebuilds aggregate run statistics from the `.log` files
on disk at startup.
| Test | Purpose |
|------|---------|
| `TestSeedStatsBasic` | Verifies run/fail counts and the last, average, and maximum durations parsed from a job's log headers. |
| `TestSeedStatsDurationLessLegacyLog` | Verifies a log written before the `duration` header still counts as a run but is excluded from the duration aggregates, so a missing duration cannot masquerade as a 0 ms run. |
| `TestSeedStatsMaxFilesHonoured` | Verifies that only the newest `MaxLogFiles` logs are parsed when the limit is positive. |
| `TestSeedStatsMissingDir` | Verifies a missing logs directory yields an empty map rather than an error or a panic. |
| `TestSeedStatsUnknownJobProducesNoEntry` | Verifies log files that match no known job are ignored. |
| `TestSeedStatsMatchesByJobID` | Verifies logs are matched by the `job_id` header even when two job names sanitize to the same filename. |
---
### src/runner/cleanup_test.go
**Package:** `runner`
Tests log-file cleanup by age and by count.
| Test | Purpose |
|------|---------|
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. |
| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. |
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
| `TestCleanupLogsZeroLimitsDisableBothPolicies` | Verifies that setting both limits to zero disables both the age and count cleanup policies. |
---
### src/platform/autostart/autostart_windows_test.go
**Location:** `src/platform/autostart/autostart_windows_test.go`
**Build Tags:** `//go:build windows`
Tests Windows autostart via shortcuts in the Startup folder.
| Test | Purpose |
|------|---------|
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. |
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
---
### src/platform/autostart/autostart_linux_test.go
**Location:** `src/platform/autostart/autostart_linux_test.go`
**Build Tags:** `//go:build linux`
Tests Linux autostart via XDG Desktop Entry files.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
---
### src/platform/desktop/desktop_linux_test.go
**Location:** `src/platform/desktop/desktop_linux_test.go`
**Build Tags:** `//go:build linux`
Tests Linux desktop integration (`.desktop` file and icon under XDG data home).
| Test | Purpose |
|------|---------|
| `TestInstallDesktopIntegrationWritesDesktopAndIcon` | Verifies `.desktop` and PNG icon files are written under `$XDG_DATA_HOME`. |
| `TestQuoteDesktopExecQuotesPath` | Verifies `Exec=` paths with spaces are shell-quoted. |
---
### src/platform/filemanager/filemanager_test.go
**Package:** `filemanager`
Tests the guards around opening a folder in the desktop file manager. The
success path is not tested: it would open a real file manager window.
| Test | Purpose |
|------|---------|
| `TestOpenRejectsMissingFolder` | Verifies that `Open` reports a missing directory (naming the path) instead of launching a handler. |
| `TestOpenRejectsFile` | Verifies that `Open` refuses a path that is a file rather than a directory. |
| `TestOpenCommandNamesPlatformHandler` | Verifies the per-platform handler (`explorer` / `xdg-open`, none elsewhere) and that the path is passed as one argument. |
---
### src/ui/jobs_view_test.go
**Package:** `ui`
Tests the Jobs tab: pure filter helpers, and — through Fyne's headless
`test.NewApp()` — the geometry and redraw behaviour that only shows up once the
widgets are assembled.
| Test | Purpose |
|------|---------|
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. |
| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. |
| `TestFilteredJobIndexesAll` | Verifies that the "All" filter returns indexes for every job. |
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
---
### src/ui/history_view_test.go
**Package:** `ui`
Tests the History tab: the pure activity helpers and the sorted-snapshot and
column-width behaviour of the assembled table.
| Test | Purpose |
|------|---------|
| `TestCollectActivityMergesAndSorts` | Verifies per-job logs are merged and sorted by time. |
| `TestCollectActivitySkipsMissingRuntimes` | Verifies missing runtime entries are skipped safely. |
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
| `TestLastJobLogsCapsAndCopies` | Verifies activity panel cap and defensive copy semantics. |
| `TestLastJobLogsEmpty` | Verifies nil/empty log input returns an empty slice. |
| `TestIndexOfID` | Verifies job lookup by ID returns `-1` when not found. |
| `TestHistorySortToggleKeepsRowsInSync` | Regression guard for the cached sorted snapshot: the length callback and the cells must be refilled together, or the row count and the cell contents disagree. |
| `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. |
| `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. |
| `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. |
---
### src/ui/settings_view_test.go
**Package:** `ui`
Tests the Settings tab helpers and the row layout.
| Test | Purpose |
|------|---------|
| `TestSettingsFolderPath` | Verifies the folder the Logs directory "Open" button targets: blank text yields no path, a relative path resolves against the application directory, an absolute path is used as typed. |
| `TestSettingsRowStretchesItsControl` | Verifies the row's centre slot already stretches the control to the column width — the property that made a fixed-width wrapper around it redundant. |
| `TestChooseFileAppliesFilter` | Verifies the deduplicated picker opens a dialog both with a nil filter (the command browser) and with a concrete one (`chooseJSONFile`). |
| `TestSettingsCaptionsCoverEveryRow` | Verifies every caption used in a row is present in `settingsCaptions` and fits the measured caption column. |
---
### src/ui/layout_test.go
**Package:** `ui`
Tests the theme-derived sizing helpers in `layout.go`.
| Test | Purpose |
|------|---------|
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
---
### src/ui/theme_test.go
**Package:** `ui`
Tests the branded theme and the stored theme choice.
| Test | Purpose |
|------|---------|
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
| `TestThemeForChoice` | Verifies the GoSentry choice yields the branded primary and every other value — including the empty legacy one — yields the default theme. |
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. |
---
### src/ui/mainwindow_test.go
**Package:** `ui`
Tests main view construction with an injected `*app.Service`.
| Test | Purpose |
|------|---------|
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
| `TestMainViewBuilds` | Verifies `newMainView` assembles tabs without panic using `fyne.io/fyne/v2/test`. |
---
## Test Design Principles
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
2. **Cross-platform** — Platform-specific tests use `//go:build` tags and `runtime.GOOS` checks to skip when not applicable.
3. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly.
3. **Fake clocks and runners** — The scheduler is exercised with an injected fake `Clock`; the service operations tests inject a fake `runJob` function to avoid spawning real processes.
4. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
4. **Event-driven correctness**`app` tests subscribe to the event bus and assert that the expected events are emitted, rather than inspecting internal fields directly.
5. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
5. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
6. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
7. **Regression on serious fixes** — Any fix from an internal review with severity ≥ medium gets a targeted regression test (see `run_test.go` for examples).
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
---
## Future Test Coverage Gaps
## Remaining Test Coverage Gaps
Potential areas for additional tests:
- Job group/folder filtering and persistence
- Log cleanup (max file count and max age)
- Settings persistence and migration
- GUI integration tests (currently untested)
- Concurrent job execution
- Job history and run record storage
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
+28 -24
View File
@@ -3,39 +3,43 @@ module gitea.mixdep.ru/mix/gosentry
go 1.22
require (
fyne.io/fyne/v2 v2.5.3
fyne.io/fyne/v2 v2.7.4
github.com/robfig/cron/v3 v3.0.1
go.yaml.in/yaml/v4 v4.0.0-rc.5
)
require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
fyne.io/systray v1.12.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.0 // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.3.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/yuin/goldmark v1.7.1 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.16.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+59 -633
View File
@@ -1,660 +1,86 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8=
fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
fyne.io/fyne/v2 v2.7.4 h1:OVCI5mT+Onb2kA4wlmGA5pLCqKik9f4NDb5jiR1OMTc=
fyne.io/fyne/v2 v2.7.4/go.mod h1:ZD1mmhBY75mSa97IXl3MPlICd1uNHfCXYh5hKIlVOII=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4=
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg=
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os=
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY=
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk=
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho=
github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I=
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY=
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8=
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+9
View File
@@ -1 +1,10 @@
// The same multi-size .ico is embedded under two resource names on purpose:
// IDI_ICON1 - the lowest-id icon, which Windows Explorer / shortcuts use for the
// executable's file icon.
// GLFW_ICON - the name GLFW looks up to icon the window. Because run.go does NOT
// call a.SetIcon on Windows, GLFW falls back to this resource and
// selects the right frame per size (hand-tuned 16 for the titlebar,
// a larger frame for the taskbar). See assets/assets.go for the full
// cross-platform icon strategy.
IDI_ICON1 ICON "assets/gosentry.ico"
GLFW_ICON ICON "assets/gosentry.ico"
+4 -2
View File
@@ -4,7 +4,7 @@ set -euo pipefail
# Optional first argument mirrors build-linux.sh. The Docker build still writes
# the final artifact into the local dist/ tree, not into the container. The
# default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
@@ -18,15 +18,17 @@ fi
docker build -f Dockerfile -t "$tag" .
mkdir -p "$(dirname "$output")"
mkdir -p "$(pwd)/.gocache"
docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \
-e "OUTPUT=${output}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "$(pwd):/src" \
-v "$(pwd)/.gocache:/tmp/go-build-cache" \
-w /src \
"$tag" \
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
# Icons are embedded in the Go binary, so there is no assets directory to copy
# after extracting the Linux executable.
+2 -2
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Optional first argument lets a developer or CI job choose the output path. The
# default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')"
version="${version:-0.0.0-dev}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
mkdir -p "$(dirname "$output")"
@@ -17,7 +17,7 @@ export GOARCH=amd64
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
# symbol/debug tables to keep the desktop binary smaller.
go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${version}" -o "$output" ./cmd/gosentry
go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" -o "$output" ./cmd/gosentry
# The application icon is embedded by Go, so the Linux build does not need a
# sidecar assets directory beside the executable.
+6 -4
View File
@@ -9,7 +9,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
@@ -88,26 +88,28 @@ normalize_targets() {
}
run_in_builder() {
mkdir -p "${repo_root}/.gocache"
docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "${repo_root}:/src" \
-v "${repo_root}/.gocache:/tmp/go-build-cache" \
-w /src \
"$tag" \
bash -c "$1"
}
build_linux_amd64() {
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
}
build_linux_arm64() {
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
}
build_windows_amd64() {
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
}
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
+20 -5
View File
@@ -6,21 +6,28 @@ REM directory. Move to the repository root (the parent of scripts\) before using
REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc.
cd /d "%~dp0\.."
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V"
echo [GoSentry Windows Build]
echo.
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\app\version.go') do set "VERSION=%%~V"
if "%VERSION%"=="" set "VERSION=0.0.0-dev"
set "VERSION=%VERSION:"=%"
echo Version: %VERSION%
REM Optional first argument allows CI or a developer to choose another output
REM path. The default keeps all generated binaries under dist\ so the source tree
REM stays clean and the old bin\ folder is no longer needed.
set "OUTPUT=%~1"
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\gosentry-%VERSION%-windows-amd64.exe"
echo Output: %OUTPUT%
echo.
REM Prefer the standard Go installer path on Windows, but fall back to PATH for
REM machines where Go was installed by another package manager.
set "GOEXE=%ProgramFiles%\Go\bin\go.exe"
if not exist "%GOEXE%" set "GOEXE=go"
echo Setting up build environment...
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
REM expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH.
@@ -41,15 +48,23 @@ REM shortcuts, and the taskbar can show the GoSentry icon. The Go embed package
REM handles Fyne's runtime icon, but Explorer reads this Windows resource instead.
where windres.exe >nul 2>nul
if %ERRORLEVEL%==0 (
echo Embedding Windows icon resource...
windres.exe -O coff -o cmd\gosentry\rsrc_windows_amd64.syso packaging\windows\gosentry.rc
) else (
echo Warning: windres not found; Windows icon resource will not be embedded.
)
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
REM size, and -H=windowsgui prevents a separate console window from opening when
REM the GUI app starts from Explorer or a shortcut.
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry
if errorlevel 1 exit /b 1
echo Compiling Go binary...
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry
if errorlevel 1 (
echo Build failed with error code %ERRORLEVEL%
exit /b 1
)
REM Icons are embedded into the executable, so no assets directory is copied next
REM to the binary. Runtime YAML and log files are created by the app itself.
echo Built %OUTPUT%
REM to the binary. Runtime JSON and log files are created by the app itself.
echo.
echo Successfully built: %OUTPUT%
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
# Build and package every release artifact on a Linux host that already has the
# cross toolchain installed (native gcc + X11/OpenGL headers, the aarch64 cross
# compiler, and the MinGW-w64 toolchain for the Windows GUI binary). This is the
# non-Docker counterpart to scripts/build-release-linux.sh: the CI workflows in
# .github/ and .forgejo/ install those packages directly on the runner and then
# call this script, so the exact build/package commands live in one place and do
# not drift between the two forges.
#
# The build flags mirror the other scripts intentionally: -trimpath strips local
# paths, -s -w drops symbol/debug tables to shrink the binaries, -H=windowsgui
# suppresses the console window on Windows, and -X injects the version so the
# GUI and artifact names agree.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root"
# VERSION can be provided by CI (for a tagged release the workflow passes the tag
# without its leading "v"). Fall back to the source of truth in version.go so the
# script also works for a plain local invocation.
version="${VERSION:-$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')}"
version="${version:-0.0.0-dev}"
ldflags="-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}"
echo "Building GoSentry ${version} release artifacts"
mkdir -p dist/linux dist/windows
# --- Linux amd64 -----------------------------------------------------------
echo "==> linux/amd64"
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -buildvcs=false -trimpath -ldflags "$ldflags" \
-o "dist/linux/gosentry-${version}-linux-amd64" ./cmd/gosentry
# --- Linux arm64 (cross compiled) ------------------------------------------
echo "==> linux/arm64"
CC=aarch64-linux-gnu-gcc \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" \
CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" \
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
go build -buildvcs=false -trimpath -ldflags "$ldflags" \
-o "dist/linux/gosentry-${version}-linux-arm64" ./cmd/gosentry
# --- Windows amd64 (cross compiled with MinGW) -----------------------------
echo "==> windows/amd64"
# windres embeds the .ico into the PE resource so Explorer/taskbar show the icon.
# The .syso is suffixed windows_amd64, so Go only links it into the Windows build
# and ignores it for the Linux targets above.
x86_64-w64-mingw32-windres -O coff \
-o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc
CC=x86_64-w64-mingw32-gcc \
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \
go build -buildvcs=false -trimpath -ldflags "-H=windowsgui ${ldflags}" \
-o "dist/windows/gosentry-${version}-windows-amd64.exe" ./cmd/gosentry
# --- Package ---------------------------------------------------------------
# Each archive holds the executable plus the top-level README and CHANGELOG,
# flattened to the archive root so a user can extract straight into any folder.
# This matches the layout produced by package-linux.sh / package-windows.bat.
package_linux() {
local arch="$1"
local binary="dist/linux/gosentry-${version}-linux-${arch}"
local tarball="dist/linux/gosentry-${version}-linux-${arch}.tar.gz"
local staging="dist/linux/_staging-${arch}"
rm -rf "$staging"
mkdir -p "$staging"
cp "$binary" "$staging/gosentry"
cp README.md "$staging/README.md"
cp docs/CHANGELOG.md "$staging/CHANGELOG.md"
tar -czf "$tarball" -C "$staging" .
rm -rf "$staging"
echo "Packaged $tarball"
}
package_windows() {
local binary="dist/windows/gosentry-${version}-windows-amd64.exe"
local zipfile="gosentry-${version}-windows-amd64.zip"
local staging="dist/windows/_staging-amd64"
rm -rf "$staging"
mkdir -p "$staging"
cp "$binary" "$staging/gosentry.exe"
cp README.md "$staging/README.md"
cp docs/CHANGELOG.md "$staging/CHANGELOG.md"
# -j flattens: files land at the zip root with no staging path prefix.
( cd "$staging" && zip -j -q "../${zipfile}" ./* )
rm -rf "$staging"
echo "Packaged dist/windows/${zipfile}"
}
package_linux amd64
package_linux arm64
package_windows
echo "Release artifacts:"
find dist/linux dist/windows -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# Move to the repository root regardless of where the script is invoked from.
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go | tr -d '\r')"
version="${version:-0.0.0-dev}"
package_arch() {
local arch="$1"
local binary="dist/linux/gosentry-${version}-linux-${arch}"
local tarball="dist/linux/gosentry-${version}-linux-${arch}.tar.gz"
local staging="dist/linux/_staging-${arch}"
mkdir -p dist/linux
if [ "$arch" = "arm64" ]; then
if ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then
echo "Skipping linux/arm64: aarch64-linux-gnu-gcc not found."
return 0
fi
echo "Building linux/arm64..."
CC=aarch64-linux-gnu-gcc \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" \
CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" \
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
go build -trimpath \
-ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" \
-o "$binary" ./cmd/gosentry
else
echo "Building linux/amd64..."
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" \
-o "$binary" ./cmd/gosentry
fi
rm -rf "$staging"
mkdir -p "$staging"
cp "$binary" "$staging/gosentry"
cp README.md "$staging/README.md"
cp docs/CHANGELOG.md "$staging/CHANGELOG.md"
# -C "$staging" . puts all files at the archive root with no subdirectory.
tar -czf "$tarball" -C "$staging" .
rm -rf "$staging"
echo "Packaged $tarball"
}
package_arch amd64
package_arch arm64
+41
View File
@@ -0,0 +1,41 @@
@echo off
setlocal enabledelayedexpansion
REM Double-clicking a .bat file can start it with an arbitrary working directory.
REM Move to the repository root before using relative paths.
cd /d "%~dp0\.."
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\app\version.go') do set "VERSION=%%~V"
if "%VERSION%"=="" set "VERSION=0.0.0-dev"
set "VERSION=%VERSION:"=%"
set "ARCH=windows-amd64"
set "EXE_PATH=dist\windows\gosentry-%VERSION%-%ARCH%.exe"
set "ZIP_PATH=dist\windows\gosentry-%VERSION%-%ARCH%.zip"
set "STAGING=dist\windows\_staging-%VERSION%-%ARCH%"
REM Build the Windows executable via the shared build script.
call scripts\build-windows.bat "%EXE_PATH%"
if errorlevel 1 exit /b 1
REM Assemble portable bundle in a staging directory.
if exist "%STAGING%" rmdir /s /q "%STAGING%"
mkdir "%STAGING%"
copy "%EXE_PATH%" "%STAGING%\gosentry.exe" >nul
copy README.md "%STAGING%\README.md" >nul
copy docs\CHANGELOG.md "%STAGING%\CHANGELOG.md" >nul
REM Remove any previous zip so Compress-Archive does not append to it.
if exist "%ZIP_PATH%" del /f "%ZIP_PATH%"
REM Compress the staging contents. Each file lands at the root of the zip so
REM the user can extract directly into any folder and run gosentry.exe.
powershell -NoProfile -Command "Compress-Archive -Path '%STAGING%\*' -DestinationPath '%ZIP_PATH%'"
if errorlevel 1 (
echo Compress-Archive failed. Ensure PowerShell 5+ is available.
exit /b 1
)
rmdir /s /q "%STAGING%"
echo Packaged %ZIP_PATH%
+35
View File
@@ -0,0 +1,35 @@
@echo off
setlocal enabledelayedexpansion
REM GoSentry test runner
REM Runs go vet and go test with race detection
REM Move to repository root
cd /d "%~dp0\.."
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
REM expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH.
if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%"
REM Race detector requires CGO
set "CGO_ENABLED=1"
echo Running go vet...
go vet ./...
if errorlevel 1 (
echo.
echo ✗ go vet failed
exit /b 1
)
echo.
echo Running go test with race detection...
go test -race ./...
if errorlevel 1 (
echo.
echo ✗ go test failed
exit /b 1
)
echo.
echo ✓ All tests passed
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# GoSentry test runner
# Runs go vet and go test with race detection
set -e
echo "Running go vet..."
go vet ./...
echo ""
echo "Running go test with race detection..."
go test -race ./...
echo ""
echo "✓ All tests passed"
+112
View File
@@ -0,0 +1,112 @@
package app
import "gitea.mixdep.ru/mix/gosentry/src/domain"
// Event is something the Service did to its state that observers may want to
// react to. It is a sealed interface: the concrete types in this file are the
// only implementations (enforced by the unexported isEvent marker), so a UI
// listener can exhaustively type-switch over them and the compiler will flag a
// new event type that a switch forgot to handle.
//
// Events replace the old single onChange callback. Instead of the scheduler
// reaching into the GUI, the Service emits typed events and the UI subscribes —
// the UI's listener becomes the one place that touches widgets.
type Event interface {
isEvent()
}
// JobChanged signals that a job's durable config or transient runtime changed:
// created, edited, deleted, enabled/disabled, or a status transition such as a
// run starting. Observers should re-read the affected state through the Service
// (Jobs/Runtime) rather than expect a payload snapshot — that keeps the event
// small and avoids handing out stale copies.
//
// JobID identifies the affected job. A zero JobID means a broad change (for
// example a delete, or a global pause that touched every job) and observers
// should refresh their whole view.
type JobChanged struct {
JobID int
}
// RunRecorded signals that a job run finished and produced a RunRecord. It
// carries the record by value because the record is an immutable result that
// observers append to history; there is nothing for them to re-read.
type RunRecorded struct {
Record domain.RunRecord
}
// SchedulerStateChanged signals that the global scheduler pause state flipped.
// The UI uses it to update the pause/resume control and status text.
type SchedulerStateChanged struct {
Paused bool
}
// JobsLoaded signals that the whole job list was replaced by the contents of a
// jobs file the user selected in Settings. It carries the path and job count
// because that is what the user needs to see confirmed — the switch happens
// without a prompt, and the previous list is no longer on screen to compare
// against. Observers that render jobs should re-read them through the Service;
// a broad JobChanged is emitted alongside for exactly that.
type JobsLoaded struct {
Path string
Count int
}
// ErrorOccurred signals a background error that could not be returned to a
// caller — typically a failed save or cleanup after an async run. The UI
// surfaces it in the History tab so the user is not silently left with
// un-persisted state.
type ErrorOccurred struct {
Err error
}
func (JobChanged) isEvent() {}
func (JobsLoaded) isEvent() {}
func (RunRecorded) isEvent() {}
func (SchedulerStateChanged) isEvent() {}
func (ErrorOccurred) isEvent() {}
// Observer receives events emitted by the Service. OnEvent is the single
// reaction point; the UI implements it and marshals any widget work onto the
// main thread (fyne.Do) itself — the Service knows nothing about Fyne.
type Observer interface {
OnEvent(Event)
}
// ObserverFunc adapts a plain function to the Observer interface, so callers can
// subscribe a closure without declaring a type.
type ObserverFunc func(Event)
// OnEvent calls the wrapped function.
func (f ObserverFunc) OnEvent(event Event) { f(event) }
// Subscribe registers an observer to receive every subsequently emitted event.
// Registration is expected during setup, before the scheduler starts, but is
// guarded so it is safe at any time.
func (s *Service) Subscribe(observer Observer) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
s.observers = append(s.observers, observer)
}
// emit delivers an event to every registered observer.
//
// Single-threaded dispatch contract:
// - emit holds dispatchMu for the whole dispatch, so observers are never
// invoked concurrently and never overlap with each other or with Subscribe.
// Each observer sees events one at a time, in emit order.
// - emit must be called WITHOUT holding s.mu. The Service computes a state
// change under mu, releases it, then emits — so an observer is free to call
// back into read methods (Jobs/Runtime) without deadlocking on the state
// lock.
// - An observer must NOT call back into a Service method that emits (directly
// or indirectly): dispatchMu is non-reentrant, so re-entrant emission would
// deadlock. Observers react and return quickly; long or UI work is the
// observer's own responsibility to defer (e.g. fyne.Do).
func (s *Service) emit(event Event) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
for _, observer := range s.observers {
observer.OnEvent(event)
}
}
+59
View File
@@ -0,0 +1,59 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
svc := newTestService(nil)
var first, second []Event
svc.Subscribe(ObserverFunc(func(e Event) { first = append(first, e) }))
svc.Subscribe(ObserverFunc(func(e Event) { second = append(second, e) }))
svc.emit(JobChanged{JobID: 7})
svc.emit(RunRecorded{Record: domain.RunRecord{JobID: 7, State: "Success"}})
svc.emit(SchedulerStateChanged{Paused: true})
for name, got := range map[string][]Event{"first": first, "second": second} {
if len(got) != 3 {
t.Fatalf("%s observer got %d events, want 3", name, len(got))
}
if jc, ok := got[0].(JobChanged); !ok || jc.JobID != 7 {
t.Errorf("%s event[0] = %#v, want JobChanged{JobID:7}", name, got[0])
}
if rr, ok := got[1].(RunRecorded); !ok || rr.Record.State != "Success" {
t.Errorf("%s event[1] = %#v, want RunRecorded Success", name, got[1])
}
if ss, ok := got[2].(SchedulerStateChanged); !ok || !ss.Paused {
t.Errorf("%s event[2] = %#v, want SchedulerStateChanged{Paused:true}", name, got[2])
}
}
}
func TestEmitWithNoObserversIsNoop(t *testing.T) {
svc := newTestService(nil)
// Must not panic with an empty observer list.
svc.emit(JobChanged{})
}
// Observers may read Service state from within OnEvent without deadlocking,
// because emit is called outside the state lock.
func TestObserverCanReadServiceState(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "Job", Enabled: true}}
svc := newTestService(jobs)
var sawName string
svc.Subscribe(ObserverFunc(func(Event) {
if snapshot := svc.Jobs(); len(snapshot) == 1 {
sawName = snapshot[0].Name
}
}))
svc.emit(JobChanged{JobID: 1})
if sawName != "Job" {
t.Errorf("observer read name = %q, want %q", sawName, "Job")
}
}
+132
View File
@@ -0,0 +1,132 @@
package app
import (
"fmt"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// StatusText formats a job's current state for display: "Paused" if disabled,
// else its runtime LastState (Ready, Running, Success, etc).
func StatusText(j domain.Job, runtime *domain.JobRuntime) string {
if !j.Enabled {
return "Paused"
}
if runtime == nil {
return ""
}
return runtime.LastState
}
// EventText formats a run record for the History table, showing time, trigger,
// job name, outcome state, detail, and log file (if any).
func EventText(e domain.RunRecord) string {
trigger := e.Trigger
if trigger == "" {
trigger = "Unknown"
}
if e.LogFile != "" {
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile)
}
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
}
// EventLine formats a run record as a compact single line for the jobs log
// view, using only the base name of the log file instead of the full path.
func EventLine(e domain.RunRecord) string {
trigger := e.Trigger
if trigger == "" {
trigger = "Unknown"
}
if e.LogFile != "" {
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, filepath.Base(e.LogFile))
}
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
}
// DisplayFolder formats a job's folder for display: "(No folder)" if empty,
// else the trimmed folder name.
func DisplayFolder(folder string) string {
if strings.TrimSpace(folder) == "" {
return "(No folder)"
}
return strings.TrimSpace(folder)
}
// DisplayArguments formats a job's arguments for display: "(none)" if empty,
// else the trimmed arguments.
func DisplayArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "(none)"
}
return strings.TrimSpace(arguments)
}
// DisplayRunMode formats a job's execution mode: "Start only" or
// "Wait for completion".
func DisplayRunMode(job domain.Job) string {
if job.StartOnly {
return "Start only"
}
return "Wait for completion"
}
// DisplayInvocation formats a job's command and arguments for the jobs list,
// joining them with spacing and collapsing newlines in arguments to spaces.
func DisplayInvocation(job domain.Job) string {
if strings.TrimSpace(job.Arguments) == "" {
return job.Command
}
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
}
// DisplayStats returns a one-line execution-time summary for a job runtime.
// Returns "No runs recorded" when no runs have been counted yet.
func DisplayStats(rt *domain.JobRuntime) string {
if rt == nil || rt.RunCount == 0 {
return "No runs recorded"
}
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS)
}
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
// panel. When the job has its own policy it is shown as-is; when empty (inherit
// global), the global default is shown with "(global default)" appended.
func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) string {
if p := domain.OverlapPolicy(strings.TrimSpace(job.OverlapPolicy)); p != "" {
return string(p)
}
return string(globalPolicy) + " (global default)"
}
// DisplayTimeout formats a job's effective run timeout for the details panel.
// When the job sets its own TimeoutSeconds it is shown as-is, with an explicit 0
// rendered as "no timeout"; when unset (nil), the global default is shown with
// "(global default)" appended, mirroring DisplayOverlapPolicy. A non-positive
// global default means no timeout at all.
func DisplayTimeout(job domain.Job, globalDefault int) string {
if job.TimeoutSeconds != nil {
if *job.TimeoutSeconds <= 0 {
return "no timeout"
}
return fmt.Sprintf("%d s", *job.TimeoutSeconds)
}
if globalDefault <= 0 {
return "no timeout (global default)"
}
return fmt.Sprintf("%d s (global default)", globalDefault)
}
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
// or 0 if not found.
func DisplayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes {
if index == jobIndex {
return display
}
}
return 0
}
+183
View File
@@ -0,0 +1,183 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestStatusText(t *testing.T) {
tests := []struct {
name string
job domain.Job
runtime *domain.JobRuntime
want string
}{
{"disabled is paused", domain.Job{Enabled: false}, &domain.JobRuntime{LastState: "Running"}, "Paused"},
{"enabled shows runtime state", domain.Job{Enabled: true}, &domain.JobRuntime{LastState: "Success"}, "Success"},
{"enabled with nil runtime is empty", domain.Job{Enabled: true}, nil, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := StatusText(tc.job, tc.runtime); got != tc.want {
t.Errorf("StatusText = %q, want %q", got, tc.want)
}
})
}
}
func TestEventText(t *testing.T) {
withLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
State: "Success", Detail: "ok", LogFile: "build.log",
}
if got, want := EventText(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build.log"; got != want {
t.Errorf("EventText with log = %q, want %q", got, want)
}
noLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build",
State: "Success", Detail: "ok",
}
if got, want := EventText(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want {
t.Errorf("EventText without log = %q, want %q", got, want)
}
// An empty trigger is shown as "Unknown".
blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d"}
if got, want := EventText(blank), "t Unknown J S d"; got != want {
t.Errorf("EventText blank trigger = %q, want %q", got, want)
}
}
func TestDisplayFolder(t *testing.T) {
if got := DisplayFolder(" "); got != "(No folder)" {
t.Errorf("blank folder = %q, want %q", got, "(No folder)")
}
if got := DisplayFolder(" Reports "); got != "Reports" {
t.Errorf("folder = %q, want %q", got, "Reports")
}
}
func TestDisplayArguments(t *testing.T) {
if got := DisplayArguments(""); got != "(none)" {
t.Errorf("empty args = %q, want %q", got, "(none)")
}
if got := DisplayArguments(" -v "); got != "-v" {
t.Errorf("args = %q, want %q", got, "-v")
}
}
func TestDisplayRunMode(t *testing.T) {
if got := DisplayRunMode(domain.Job{StartOnly: true}); got != "Start only" {
t.Errorf("start-only = %q, want %q", got, "Start only")
}
if got := DisplayRunMode(domain.Job{StartOnly: false}); got != "Wait for completion" {
t.Errorf("wait = %q, want %q", got, "Wait for completion")
}
}
func TestDisplayInvocation(t *testing.T) {
if got := DisplayInvocation(domain.Job{Command: "echo"}); got != "echo" {
t.Errorf("no args = %q, want %q", got, "echo")
}
// Arguments are appended with spacing and their newlines collapsed to spaces.
job := domain.Job{Command: "echo", Arguments: " hi\nthere "}
if got, want := DisplayInvocation(job), "echo hi there"; got != want {
t.Errorf("with args = %q, want %q", got, want)
}
}
func TestDisplayIndex(t *testing.T) {
indexes := []int{4, 7, 2}
if got := DisplayIndex(indexes, 7); got != 1 {
t.Errorf("DisplayIndex(7) = %d, want 1", got)
}
// A jobIndex not present returns 0.
if got := DisplayIndex(indexes, 99); got != 0 {
t.Errorf("DisplayIndex(missing) = %d, want 0", got)
}
}
func TestDisplayStats(t *testing.T) {
// Zero RunCount → sentinel string.
if got := DisplayStats(nil); got != "No runs recorded" {
t.Errorf("nil runtime = %q, want %q", got, "No runs recorded")
}
if got := DisplayStats(&domain.JobRuntime{}); got != "No runs recorded" {
t.Errorf("zero runtime = %q, want %q", got, "No runs recorded")
}
rt := &domain.JobRuntime{
RunCount: 5,
FailCount: 2,
LastDurationMS: 450,
AvgDurationMS: 380,
MaxDurationMS: 520,
}
want := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms"
if got := DisplayStats(rt); got != want {
t.Errorf("DisplayStats = %q, want %q", got, want)
}
// Zero failures are included in the output (not hidden).
rtNoFail := &domain.JobRuntime{RunCount: 3, FailCount: 0, LastDurationMS: 100, AvgDurationMS: 90, MaxDurationMS: 110}
wantNoFail := "3 runs, 0 failed, last 100 ms, avg 90 ms, max 110 ms"
if got := DisplayStats(rtNoFail); got != wantNoFail {
t.Errorf("DisplayStats no-fail = %q, want %q", got, wantNoFail)
}
}
func TestEventLine(t *testing.T) {
withLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
State: "Success", Detail: "ok", LogFile: "/home/user/logs/build-20260619.log",
}
if got, want := EventLine(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build-20260619.log"; got != want {
t.Errorf("EventLine with log = %q, want %q", got, want)
}
noLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build",
State: "Success", Detail: "ok",
}
if got, want := EventLine(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want {
t.Errorf("EventLine without log = %q, want %q", got, want)
}
// An empty trigger is shown as "Unknown".
blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d", LogFile: "/path/to/file.log"}
if got, want := EventLine(blank), "t Unknown J S d file.log"; got != want {
t.Errorf("EventLine blank trigger = %q, want %q", got, want)
}
}
func TestDisplayOverlapPolicy(t *testing.T) {
global := domain.OverlapPolicyQueue
jobOwn := domain.Job{OverlapPolicy: string(domain.OverlapPolicySkip)}
if got, want := DisplayOverlapPolicy(jobOwn, global), "skip"; got != want {
t.Errorf("per-job policy = %q, want %q", got, want)
}
inherit := domain.Job{OverlapPolicy: ""}
if got, want := DisplayOverlapPolicy(inherit, global), "queue (global default)"; got != want {
t.Errorf("inherited policy = %q, want %q", got, want)
}
}
func TestDisplayTimeout(t *testing.T) {
own := domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(45)}
if got, want := DisplayTimeout(own, 30), "45 s"; got != want {
t.Errorf("per-job timeout = %q, want %q", got, want)
}
none := domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(0)}
if got, want := DisplayTimeout(none, 30), "no timeout"; got != want {
t.Errorf("explicit per-job zero timeout = %q, want %q", got, want)
}
inherit := domain.Job{TimeoutSeconds: nil}
if got, want := DisplayTimeout(inherit, 30), "30 s (global default)"; got != want {
t.Errorf("inherited timeout = %q, want %q", got, want)
}
if got, want := DisplayTimeout(inherit, 0), "no timeout (global default)"; got != want {
t.Errorf("inherited infinite timeout = %q, want %q", got, want)
}
}
+490
View File
@@ -0,0 +1,490 @@
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"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
// lives in the log files on disk; this is only the recent activity shown in the
// GUI, so an old run aging out of the list is intentional.
const maxJobLogs = 50
// timestampLayout matches the format used for run records so UI-action activity
// and command runs line up in the History view.
const timestampLayout = "2006-01-02 15:04:05"
// errJobNotFound is returned by the mutating operations when no loaded job has
// the requested ID.
var errJobNotFound = errors.New("job not found")
// CreateJob normalizes and validates the supplied configuration, assigns the
// next free ID, and adds it to the loaded set. It returns the stored job (with
// its assigned ID) so the caller can select it. The job is persisted and a
// "Created" activity record is emitted.
func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
normalizeJob(&job)
if err := validateJob(job); err != nil {
return domain.Job{}, err
}
s.mu.Lock()
job.ID = s.nextIDLocked()
s.jobs = append(s.jobs, job)
runtime := domain.NewRuntime(job)
s.runtimes[job.ID] = runtime
s.parseScheduleLocked(&job)
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
if err != nil {
s.jobs = s.jobs[:len(s.jobs)-1]
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return job, nil
}
// UpdateJob replaces the durable configuration of the job with the same ID,
// keeping its runtime state (keyed by ID) and recomputing its next run. The job
// is persisted and an "Updated" activity record is emitted.
func (s *Service) UpdateJob(job domain.Job) error {
normalizeJob(&job)
if err := validateJob(job); err != nil {
return err
}
s.mu.Lock()
existing := s.findByIDLocked(job.ID)
if existing == nil {
s.mu.Unlock()
return fmt.Errorf("update job %d: %w", job.ID, errJobNotFound)
}
*existing = job
runtime := s.runtimeForLocked(existing)
// An edit may have toggled Enabled; reflect that into the status the same way
// a dedicated enable/disable would, then recompute the next run.
if job.Enabled {
if runtime.LastState == "" || runtime.LastState == "Paused" {
runtime.LastState = "Ready"
}
} else {
runtime.LastState = "Paused"
}
s.parseScheduleLocked(existing)
s.refreshNextRunLocked(existing, runtime)
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return nil
}
// DeleteJob removes the job with the given ID along with its runtime and cached
// schedule. The remaining jobs are persisted and a "Deleted" activity record is
// emitted. The JobChanged event carries a zero ID to signal a broad change.
func (s *Service) DeleteJob(id int) error {
s.mu.Lock()
index := s.indexByIDLocked(id)
if index < 0 {
s.mu.Unlock()
return fmt.Errorf("delete job %d: %w", id, errJobNotFound)
}
deleted := s.jobs[index]
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
delete(s.runtimes, id)
delete(s.schedules, id)
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: 0})
return nil
}
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
// and recomputes its next run (respecting the global pause); disabling parks it
// at "Paused". The job is persisted and a "Resumed"/"Paused" activity record is
// emitted.
func (s *Service) SetEnabled(id int, enabled bool) error {
s.mu.Lock()
job := s.findByIDLocked(id)
if job == nil {
s.mu.Unlock()
return fmt.Errorf("set enabled job %d: %w", id, errJobNotFound)
}
job.Enabled = enabled
runtime := s.runtimeForLocked(job)
s.parseScheduleLocked(job)
var record domain.RunRecord
if enabled {
runtime.LastState = "Ready"
s.refreshNextRunLocked(job, runtime)
record = uiRecord(id, job.Name, "Resumed", "Job was enabled")
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
runtime.NextDue = time.Time{}
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: id})
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)
s.refreshNextRunFromLocked(job, runtime, now)
}
err := s.store.SaveConfig()
if err == nil {
err = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock()
if 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
err := s.store.SaveConfig()
s.mu.Unlock()
return err
}
// 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()
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
switching := jobsPath != s.store.Paths.JobsPath
if switching && s.anyRunningLocked() {
s.mu.Unlock()
return errors.New("cannot change the jobs file while a job is running")
}
// Read the new file before anything is written, so a file that cannot be
// parsed leaves both the config and the current jobs untouched.
var adopted []domain.Job
if switching {
jobs, found, err := storage.LoadJobsFile(jobsPath)
if err != nil {
s.mu.Unlock()
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
}
if found {
adopted = jobs
}
}
s.store.Config = config
if err := s.store.SaveConfig(); err != nil {
s.mu.Unlock()
return err
}
if adopted != nil {
s.adoptJobsLocked(adopted)
}
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
// jobs are written back too, which persists the IDs and defaults that
// normalization filled in, exactly as loading them at startup would.
if err := s.store.SaveJobs(s.jobs); err != nil {
s.mu.Unlock()
return err
}
loaded := len(s.jobs)
logsDir := s.store.Paths.LogsDir
maxFiles := s.store.Config.MaxLogFiles
maxAge := s.store.Config.MaxLogAgeDays
s.mu.Unlock()
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.
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
s.emit(JobChanged{})
}
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")
}
if config.MaxLogFiles <= 0 {
return errors.New("max log files must be a positive number")
}
if config.MaxLogAgeDays <= 0 {
return errors.New("max log age days must be 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 default on load, so older
// configs (and hand-built ones) stay valid without an explicit theme.
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
return errors.New("theme must be 'default' or 'gosentry'")
}
return nil
}
+855
View File
@@ -0,0 +1,855 @@
package app
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
// newTempService builds a Service backed by a store rooted in a temp directory,
// so the mutating operations can persist to real (throwaway) files.
func newTempService(t *testing.T, jobs []domain.Job) *Service {
t.Helper()
dir := t.TempDir()
store := &storage.Store{
Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir,
ConfigPath: filepath.Join(dir, "gosentry.json"),
JobsDir: dir,
JobsPath: filepath.Join(dir, "jobs.json"),
LogsDir: filepath.Join(dir, "logs"),
},
Config: domain.Config{JobsFile: "jobs.json", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
}
return NewService(store, jobs)
}
// recorder is a test observer that captures every emitted event.
type recorder struct {
events []Event
}
func (r *recorder) OnEvent(e Event) { r.events = append(r.events, e) }
func (r *recorder) jobChanged() (ids []int) {
for _, e := range r.events {
if jc, ok := e.(JobChanged); ok {
ids = append(ids, jc.JobID)
}
}
return ids
}
func (r *recorder) records() (out []domain.RunRecord) {
for _, e := range r.events {
if rr, ok := e.(RunRecorded); ok {
out = append(out, rr.Record)
}
}
return out
}
func TestCreateJobAssignsIDAndEmits(t *testing.T) {
svc := newTempService(t, nil)
rec := &recorder{}
svc.Subscribe(rec)
created, err := svc.CreateJob(domain.Job{Name: "Build", Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Fatalf("CreateJob: %v", err)
}
if created.ID != 1 {
t.Errorf("first job ID = %d, want 1", created.ID)
}
if got := svc.Jobs(); len(got) != 1 || got[0].Name != "Build" {
t.Fatalf("jobs after create = %+v", got)
}
if rt := svc.Runtime(1); rt == nil || rt.LastState != "Ready" {
t.Errorf("runtime = %+v, want LastState Ready", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Created" {
t.Errorf("records = %+v, want one Created", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 1 {
t.Errorf("JobChanged ids = %v, want [1]", ids)
}
// A second job takes the next free ID.
second, err := svc.CreateJob(domain.Job{Name: "Two", Schedule: "@every 1m", Command: "echo two"})
if err != nil {
t.Fatalf("CreateJob 2: %v", err)
}
if second.ID != 2 {
t.Errorf("second job ID = %d, want 2", second.ID)
}
}
func TestCreateJobValidates(t *testing.T) {
svc := newTempService(t, nil)
if _, err := svc.CreateJob(domain.Job{Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected error for missing name")
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("invalid job should not be stored, jobs = %+v", got)
}
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
t.Error("expected error for invalid overlap policy")
}
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(-1)}); err == nil {
t.Error("expected error for negative per-job timeout")
}
// An explicit 0 is a valid choice ("no timeout"), not a rejected one.
if _, err := svc.CreateJob(domain.Job{Name: "Zero", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(0)}); err != nil {
t.Errorf("explicit zero per-job timeout should be accepted: %v", err)
}
}
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}})
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "New", Schedule: "@every 1m", Command: "echo", Enabled: false}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
got := svc.Jobs()
if got[0].Name != "New" || got[0].Enabled {
t.Errorf("job after update = %+v", got[0])
}
if rt := svc.Runtime(5); rt == nil || rt.LastState != "Paused" || rt.NextRun != "Paused" {
t.Errorf("runtime after disable = %+v", rt)
}
}
func TestUpdateJobReenablesPausedJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if rt := svc.Runtime(5); rt.LastState != "Paused" {
t.Fatalf("precondition: runtime = %+v, want Paused", rt)
}
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt)
}
}
// runtimeForLocked lazily recreates a missing runtime entry so the Service stays
// robust if a job somehow lacks one. Dropping the entry and driving an operation
// that needs it exercises that path.
func TestRuntimeLazilyRecreated(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
delete(svc.runtimes, 1)
svc.mu.Unlock()
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled: %v", err)
}
if rt := svc.Runtime(1); rt == nil {
t.Error("runtime was not lazily recreated")
}
}
func TestUpdateJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected not-found error")
}
}
func TestDeleteJobRemovesEverything(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.DeleteJob(1); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("jobs after delete = %+v", got)
}
if rt := svc.Runtime(1); rt != nil {
t.Errorf("runtime should be gone, got %+v", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Deleted" {
t.Errorf("records = %+v, want one Deleted", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 0 {
t.Errorf("JobChanged ids = %v, want [0] (broad)", ids)
}
}
func TestDeleteJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.DeleteJob(42); err == nil {
t.Error("expected not-found error deleting unknown job")
}
}
func TestSetEnabledNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetEnabled(42, true); err == nil {
t.Error("expected not-found error enabling unknown job")
}
}
func TestSetEnabledToggles(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled true: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("enabled runtime = %+v, want Ready with a next-due", rt)
}
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Paused" || !rt.NextDue.IsZero() {
t.Errorf("disabled runtime = %+v, want Paused with no next-due", rt)
}
}
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
svc := newTempService(t, []domain.Job{
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
{ID: 2, Name: "Off", Schedule: "@every 1m", Command: "echo", Enabled: false},
})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
if rt := svc.Runtime(1); rt.NextRun != "Scheduler paused" {
t.Errorf("enabled job next-run = %q, want %q", rt.NextRun, "Scheduler paused")
}
if rt := svc.Runtime(2); rt.NextRun != "Paused" {
t.Errorf("disabled job next-run = %q, want %q", rt.NextRun, "Paused")
}
var sawState bool
for _, e := range rec.events {
if ss, ok := e.(SchedulerStateChanged); ok && ss.Paused {
sawState = true
}
}
if !sawState {
t.Error("expected a SchedulerStateChanged{Paused:true} event")
}
// Resuming recomputes a real next run for the enabled job.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("resume: %v", err)
}
if rt := svc.Runtime(1); rt.NextDue.IsZero() {
t.Errorf("resumed enabled job should have a next-due, got %+v", rt)
}
}
func TestRunNowUsesRunnerAndRecords(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Manual" {
t.Errorf("trigger = %q, want Manual", trigger)
}
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
select {
case done <- rr.Record:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow: %v", err)
}
select {
case record := <-done:
if record.State != "Success" {
t.Errorf("recorded state = %q, want Success", record.State)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for run to be recorded")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after run = %+v", rt)
}
}
func TestRunNowNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.RunNow(99); err == nil {
t.Error("expected not-found error for unknown job")
}
}
func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
// Park the job in the "Running" state so a second RunNow must refuse: the
// runner signals once it has started and then blocks until released.
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- struct{}{}
<-release
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := make(chan struct{}, 1)
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" {
select {
case done <- struct{}{}:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("first RunNow: %v", err)
}
<-entered // the run is now in-flight and blocked
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while already running")
}
close(release)
// Wait for the in-flight run to finish before returning so its background
// writes complete before t.TempDir cleanup removes the directory.
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the in-flight run to complete")
}
// Only the first run should ever have reached the runner.
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d times, want 1", got)
}
}
func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}, nil
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
// Pause stops only scheduled runs; an explicit manual run is still allowed.
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow should be allowed while paused: %v", err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("runner was not invoked for a manual run while paused")
}
}
func TestRunDueStartsDueJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Schedule" {
t.Errorf("trigger = %q, want Schedule", trigger)
}
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
select {
case done <- rr.Record:
default:
}
}
}))
// The job's next-due was primed ~1m ahead at construction; tick well past it.
svc.RunDue(time.Now().Add(2 * time.Minute))
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("RunDue did not start the due job")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after scheduled run = %+v", rt)
}
}
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
// Next-due is ~1m out, so nothing is due "now".
svc.RunDue(time.Now())
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job before it was due")
}
}
// TestRunDueSkipsJobInRunningState verifies that RunDue will not start a second
// concurrent instance of a job that is already in "Running" state — even if the
// job's NextDue is in the past. This guards against the window between
// executeRun completing and refreshNextRunLocked setting a new NextDue.
func TestRunDueSkipsJobInRunningState(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var calls int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"}, nil
}
// Force the job into "Running" with a past NextDue, simulating an in-flight
// run. We set NextDue to a past time so the due check would otherwise pass.
svc.mu.Lock()
rt := svc.runtimes[1]
rt.LastState = "Running"
rt.NextDue = time.Now().Add(-time.Minute)
svc.mu.Unlock()
svc.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&calls); got != 0 {
t.Errorf("RunDue called runner %d time(s) for a job in Running state, want 0", got)
}
}
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
svc.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job while globally paused")
}
}
// appFakeClock is a scheduler.Clock whose tick and "now" the test controls, used
// to verify Start wires the loop to RunDue without the wall clock.
type appFakeClock struct {
ticks chan time.Time
now time.Time
}
func (c *appFakeClock) Now() time.Time { return c.now }
func (c *appFakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *appFakeClock) Stop() {}
func TestStartDrivesRunDueOnTick(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}, nil
}
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
svc.StartWith(clock)
defer svc.Stop()
clock.ticks <- clock.now
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Start did not drive a run from a clock tick")
}
}
func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = 0
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for non-positive max log files")
}
good := svc.store.Config
good.NotifyOnFailure = false
good.MaxLogAgeDays = 7
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.Store().Config)
}
}
func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
svc := newTempService(t, nil)
base := svc.store.Config
tests := []struct {
name string
mutate func(c *domain.Config)
}{
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := base
tc.mutate(&cfg)
if err := svc.UpdateSettings(cfg); err == nil {
t.Errorf("expected validation error for %s", tc.name)
}
})
}
}
func TestHasFileName(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"jobs.json", true},
{filepath.Join("data", "team.json"), true},
{"jobs" + string(filepath.Separator), false},
{"data/", false},
{".", false},
{"..", false},
{string(filepath.Separator), false},
}
for _, tc := range tests {
if got := hasFileName(tc.path); got != tc.want {
t.Errorf("hasFileName(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
// Renaming or relocating the jobs file writes the loaded jobs to the new path,
// which is what makes the Settings change take effect without a restart.
func TestUpdateSettingsWritesJobsToTheNewFile(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Kept", Schedule: "@every 1m", Command: "echo hi", Enabled: true}})
config := svc.store.Config
config.JobsFile = filepath.Join("data", "team-jobs.json")
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
moved := filepath.Join(svc.store.Paths.AppDir, "data", "team-jobs.json")
if svc.store.Paths.JobsPath != moved {
t.Errorf("JobsPath: got %q, want %q", svc.store.Paths.JobsPath, moved)
}
data, err := os.ReadFile(moved)
if err != nil {
t.Fatalf("read moved jobs file: %v", err)
}
var file domain.JobsFile
if err := json.Unmarshal(data, &file); err != nil {
t.Fatalf("unmarshal moved jobs file: %v", err)
}
if len(file.Jobs) != 1 || file.Jobs[0].Name != "Kept" {
t.Errorf("moved jobs file: got %+v, want the single 'Kept' job", file.Jobs)
}
}
// Pointing Settings at a jobs file that already exists must adopt that file:
// its jobs replace the loaded ones instead of being overwritten by them. This is
// the only way the user can switch between job lists, so the file's contents
// win, the job list is rebuilt around them, and History is told where they came
// from.
func TestUpdateSettingsAdoptsExistingJobsFile(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
rec := &recorder{}
svc.Subscribe(rec)
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
existing := domain.JobsFile{Jobs: []domain.Job{
{ID: 4, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
{Name: "Needs an ID", Schedule: "@every 9m", Command: "echo second", Enabled: false},
}}
data, err := json.Marshal(existing)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
jobs := svc.Jobs()
if len(jobs) != 2 || jobs[0].Name != "Adopted" {
t.Fatalf("jobs after adoption: got %+v, want the two jobs from the selected file", jobs)
}
// The adopted jobs must be fully live, not just listed: runtime and parsed
// schedule are rebuilt for the IDs the file brought (including the one
// normalization had to assign).
for _, job := range jobs {
if svc.Runtime(job.ID) == nil {
t.Errorf("job %d (%q) has no runtime after adoption", job.ID, job.Name)
}
}
if svc.Runtime(1) != nil {
t.Error("runtime of the replaced job should be gone")
}
var loaded []JobsLoaded
for _, e := range rec.events {
if jl, ok := e.(JobsLoaded); ok {
loaded = append(loaded, jl)
}
}
if len(loaded) != 1 || loaded[0].Path != shared || loaded[0].Count != 2 {
t.Errorf("JobsLoaded events: got %+v, want one for %q with 2 jobs", loaded, shared)
}
}
// A path with no file behind it is the "rename or relocate" case: the current
// jobs are written there rather than an empty list being adopted.
func TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
config := svc.store.Config
config.JobsFile = filepath.Join("moved", "jobs.json")
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
jobs := svc.Jobs()
if len(jobs) != 1 || jobs[0].Name != "Local" {
t.Fatalf("jobs after the move: got %+v, want the original job", jobs)
}
if _, err := os.Stat(filepath.Join(svc.store.Paths.AppDir, "moved", "jobs.json")); err != nil {
t.Errorf("jobs should have been written to the new path: %v", err)
}
}
// Adoption throws away every runtime, including the state of a run in flight,
// and a finishing run would then write its result onto whichever job inherited
// its ID. Refusing the switch is what keeps that from happening.
func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Long", Schedule: "@every 1h", Command: "echo long", Enabled: true}})
entered := make(chan int, 1)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow: %v", err)
}
<-entered
config := svc.store.Config
config.JobsFile = filepath.Join("elsewhere", "jobs.json")
if err := svc.UpdateSettings(config); err == nil {
t.Error("expected the jobs-file switch to be refused while a job is running")
}
if svc.Store().Config.JobsFile == config.JobsFile {
t.Error("the refused switch must not have been persisted")
}
// A setting that does not touch the jobs file still saves during a run.
unrelated := svc.Store().Config
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
if err := svc.UpdateSettings(unrelated); err != nil {
t.Errorf("unrelated setting should still save during a run: %v", err)
}
close(release)
waitRecord(t, done)
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
prependLog(runtime, domain.RunRecord{Detail: "r"})
}
if len(runtime.Logs) != maxJobLogs {
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
}
}
func TestSetGlobalPausePersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if !cfg.Paused {
t.Error("persisted config does not have Paused=true after SetGlobalPause(true)")
}
// Resuming clears the flag on disk.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("SetGlobalPause(false): %v", err)
}
data, err = os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file after resume: %v", err)
}
var cfg2 domain.Config
if err := json.Unmarshal(data, &cfg2); err != nil {
t.Fatalf("unmarshalling config after resume: %v", err)
}
if cfg2.Paused {
t.Error("persisted config still has Paused=true after SetGlobalPause(false)")
}
}
func TestSetJobListViewPersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
readConfig := func(stage string) domain.Config {
t.Helper()
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file %s: %v", stage, err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config %s: %v", stage, err)
}
return cfg
}
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if got := readConfig("after compact").JobListView; got != domain.JobListViewCompact {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewCompact)
}
if err := svc.SetJobListView(domain.JobListViewDetailed); err != nil {
t.Fatalf("SetJobListView(detailed): %v", err)
}
if got := readConfig("after detailed").JobListView; got != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewDetailed)
}
}
// TestSetJobListViewNormalizesUnknownValue guards the config file against
// gaining a value no reader understands: anything but "compact" is stored as
// "detailed".
func TestSetJobListViewNormalizesUnknownValue(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if err := svc.SetJobListView("tiny"); err != nil {
t.Fatalf("SetJobListView(tiny): %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if cfg.JobListView != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", cfg.JobListView, domain.JobListViewDetailed)
}
}
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
svc := newTempService(t, jobs)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
// Simulate a restart: NewService reads Config.Paused from the store that
// SetGlobalPause already updated (both in memory and on disk).
svc2 := NewService(svc.store, svc.Jobs())
var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
default:
}
return domain.RunRecord{}, nil
}
// RunDue must not start any job while paused: the scheduler stays paused after
// a restart that rebuilt the service from a paused store.
svc2.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store")
}
// A manual RunNow is still allowed while paused — pause only stops the
// scheduler, not the user's explicit action.
if err := svc2.RunNow(1); err != nil {
t.Errorf("RunNow should be allowed while paused: %v", err)
}
select {
case <-runStarted:
case <-time.After(2 * time.Second):
t.Error("manual run was not started on a service rebuilt from a paused store")
}
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
)
// InstallDesktopIcon installs the application's .desktop file and icon on
// Linux (no-op on other platforms). The resulting icon path is stored in
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
}
}
// AutostartStatus reports whether the platform autostart entry matches the
// current StartOnLogin setting in the stored config.
func (s *Service) AutostartStatus() (ok bool, message string) {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
execPath := s.store.Paths.ExecutablePath
manager := s.manager
s.mu.Unlock()
if manager == nil {
return false, "autostart not available"
}
return manager.Status(enabled, execPath)
}
// ApplyAutostart writes or removes the platform autostart entry to match the
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
func (s *Service) ApplyAutostart() error {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
execPath := s.store.Paths.ExecutablePath
iconPath := s.store.Paths.DesktopIcon
manager := s.manager
s.mu.Unlock()
if manager == nil {
return nil
}
return manager.Set(enabled, execPath, iconPath)
}
+287
View File
@@ -0,0 +1,287 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner"
)
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
// automatic runs (see RunDue), so a manual "Run now" is allowed even while
// paused — it is the user's explicit, one-off action. It will not start a job
// that is already running. In sequential execution mode it also refuses while
// any other job is running, so a manual run never breaks the one-at-a-time
// guarantee. The run itself happens on a background goroutine that records the
// result through the Service, so RunNow returns as soon as the run is started.
// The error reports why a run could not be started (or a failure to persist the
// "Running" status), not the run's own outcome.
func (s *Service) RunNow(id int) error {
s.mu.Lock()
job := s.findByIDLocked(id)
if job == nil {
s.mu.Unlock()
return fmt.Errorf("run job %d: %w", id, errJobNotFound)
}
runtime := s.runtimeForLocked(job)
if runtime.LastState == "Running" {
s.mu.Unlock()
return fmt.Errorf("job %d is already running", id)
}
if s.store.Config.ExecutionMode == domain.ExecutionModeSequential && s.anyRunningLocked() {
s.mu.Unlock()
return errors.New("another job is already running (sequential mode)")
}
err := s.startRunLocked(job, runtime, "Manual", time.Now())
s.mu.Unlock()
if err == nil {
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
}
return err
}
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
// the given time. It is a no-op while globally paused. Run results are recorded
// back through the Service, so the Service stays the sole writer of job and
// runtime state. The time is supplied by the scheduler's clock, which lets tests
// drive due-evaluation deterministically.
//
// Dispatch obeys two configured knobs. The execution mode decides whether
// distinct due jobs run together (parallel) or one at a time (sequential): in
// sequential mode a due job is left for a later tick while any other job is
// running. The overlap policy decides what happens when a job comes due again
// while its own previous run is still in flight: "skip" drops the new run,
// "queue" increments PendingRuns so executeRun drains missed occurrences after
// the current run finishes. Either way NextDue is advanced past the fired occurrence so the same
// moment is not re-evaluated on every tick.
func (s *Service) RunDue(now time.Time) {
s.mu.Lock()
var started []int
var startErr error
if !s.paused {
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
running := s.anyRunningLocked()
for index := range s.jobs {
job := &s.jobs[index]
runtime := s.runtimeForLocked(job)
if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) {
continue
}
if runtime.LastState == "Running" {
// The job came due again while its own run is still in flight.
// Apply the effective overlap policy and step past this
// occurrence.
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
runtime.PendingRuns++
}
s.advanceNextDueLocked(job, runtime, now)
continue
}
if sequential && running {
// One-at-a-time: leave this job due and pick it up on a later
// tick once the in-flight run has finished.
continue
}
if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
startErr = err
continue
}
started = append(started, job.ID)
running = true
}
}
s.mu.Unlock()
if startErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
}
for _, id := range started {
s.emit(JobChanged{JobID: id})
}
}
// runEnv snapshots path and retention settings for one background run so
// executeRun does not read store.Paths or store.Config without holding mu.
type runEnv struct {
logsDir string
maxFiles int
maxAge int
timeout time.Duration
}
// startRunLocked transitions a job to "Running", advances its NextDue to the next
// scheduled occurrence, persists that, and launches the run on a background
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
// firing during a long run and apply the overlap policy. The caller must hold mu.
// now is the reference time for next-due advancement and the running placeholder.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
jobCopy := *job
prevState := runtime.LastState
prevNextRun := runtime.NextRun
prevOutput := runtime.Output
prevNextDue := runtime.NextDue
runtime.LastState = "Running"
runtime.NextRun = "Running"
runtime.Output = runningOutput(jobCopy, trigger, now)
s.advanceNextDueLocked(job, runtime, now)
if err := s.store.SaveJobs(s.jobs); err != nil {
runtime.LastState = prevState
runtime.NextRun = prevNextRun
runtime.Output = prevOutput
runtime.NextDue = prevNextDue
return err
}
env := runEnv{
logsDir: s.store.Paths.LogsDir,
maxFiles: s.store.Config.MaxLogFiles,
maxAge: s.store.Config.MaxLogAgeDays,
timeout: s.effectiveTimeout(job),
}
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
// from under the goroutine after we release mu.
go s.executeRun(s.ctx, jobCopy, trigger, env)
return nil
}
// executeRun runs the job off the lock, then records the result back through the
// Service under the lock and announces it. If the job was marked Pending while
// running (the "queue" overlap policy), and it is still enabled and the scheduler
// is not paused, deferred runs are started one at a time until PendingRuns reaches
// zero. Each deferred run runs on its own goroutine.
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
s.mu.Lock()
var cleanupErr, saveErr error
var rerunStarted bool
if current := s.findByIDLocked(jobCopy.ID); current != nil {
runtime := s.runtimeForLocked(current)
runtime.LastRun = record.Time
runtime.LastState = record.State
runtime.Output = record.Output
prependLog(runtime, record)
updateStats(runtime, record)
rerun := runtime.PendingRuns > 0 && current.Enabled && !s.paused
if rerun {
runtime.PendingRuns--
// A scheduled occurrence fired while this run was active under the
// "queue" policy; start one deferred run now.
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
rerunStarted = saveErr == nil
} else {
s.refreshNextRunLocked(current, runtime)
saveErr = s.store.SaveJobs(s.jobs)
}
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
}
s.mu.Unlock()
if logErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
}
if cleanupErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
}
if saveErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
}
s.emit(RunRecorded{Record: record})
if !rerunStarted {
s.emit(JobChanged{JobID: jobCopy.ID})
}
}
// effectiveOverlapPolicy resolves the overlap policy that actually governs a
// job: the job's own value when set, otherwise the global Config default. An
// empty Job.OverlapPolicy means "inherit the global default", which is why
// normalizeJobs leaves it empty rather than backfilling the configured value.
func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
if policy := domain.OverlapPolicy(strings.TrimSpace(job.OverlapPolicy)); policy != "" {
return policy
}
return s.store.Config.OverlapPolicy
}
// effectiveTimeout resolves the run timeout that actually governs a job: the
// job's own TimeoutSeconds whenever it is set — including an explicit 0, which
// means "no timeout" and deliberately does not inherit — otherwise the global
// Config.DefaultTimeoutSeconds. A nil Job.TimeoutSeconds means "inherit the
// global default", which is why normalizeJob leaves it nil rather than
// backfilling the configured value. A resolved duration of 0 means no timeout;
// runner.RunJob treats it as "run without a deadline". The caller must hold mu.
func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
secs := s.store.Config.DefaultTimeoutSeconds
if job.TimeoutSeconds != nil {
secs = *job.TimeoutSeconds
}
return time.Duration(secs) * time.Second
}
// anyRunningLocked reports whether any loaded job is currently in the "Running"
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
// must hold mu.
func (s *Service) anyRunningLocked() bool {
for index := range s.jobs {
runtime, ok := s.runtimes[s.jobs[index].ID]
if ok && runtime != nil && runtime.LastState == "Running" {
return true
}
}
return false
}
// advanceNextDueLocked moves a job's NextDue to the next scheduled time after
// from, leaving the NextRun display string untouched so callers can keep it
// showing "Running" during a run. A missing schedule cache (an unparseable
// schedule) zeroes NextDue. The caller must hold mu.
func (s *Service) advanceNextDueLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
sched, ok := s.schedules[job.ID]
if !ok {
runtime.NextDue = time.Time{}
return
}
runtime.NextDue = sched.Next(from)
}
// updateStats folds one completed RunRecord into the runtime's aggregate
// execution-time statistics. Called under mu inside executeRun.
func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
rt.RunCount++
if r.State == "Failed" {
rt.FailCount++
}
if r.DurationMS <= 0 {
return
}
rt.LastDurationMS = r.DurationMS
if r.DurationMS > rt.MaxDurationMS {
rt.MaxDurationMS = r.DurationMS
}
rt.TimedRunCount++
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.TimedRunCount-1) + r.DurationMS) / int64(rt.TimedRunCount)
}
// runningOutput is the placeholder output shown while a job is running, before
// the real command output replaces it.
func runningOutput(job domain.Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format(timestampLayout) + "\n\n")
builder.WriteString("trigger:\n")
builder.WriteString(trigger + "\n\n")
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(runner.LogArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
+648
View File
@@ -0,0 +1,648 @@
package app
import (
"context"
"os"
"sync/atomic"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// newQueueService builds a temp-backed Service with a chosen execution mode and
// overlap policy. Config is set before any run starts, so mutating it directly is
// safe (construction is single-threaded).
func newQueueService(t *testing.T, mode domain.ExecutionMode, policy domain.OverlapPolicy, jobs []domain.Job) *Service {
t.Helper()
svc := newTempService(t, jobs)
svc.store.Config.ExecutionMode = mode
svc.store.Config.OverlapPolicy = policy
return svc
}
// primeDue forces the given jobs to be due by backdating their NextDue. Tests tick
// RunDue at the real wall clock; a long "@every 1h" schedule then advances a
// started job's NextDue an hour out, so it does not spuriously re-fire on a later
// tick unless a test re-primes it.
func primeDue(t *testing.T, svc *Service, ids ...int) {
t.Helper()
svc.mu.Lock()
defer svc.mu.Unlock()
for _, id := range ids {
if rt := svc.runtimes[id]; rt != nil {
rt.NextDue = time.Now().Add(-time.Second)
}
}
}
// completions subscribes a recorder that forwards every RunRecorded onto a
// channel, so tests can wait for runs to finish (and drain in-flight runs before
// the temp dir is cleaned up).
func completions(svc *Service) <-chan domain.RunRecord {
ch := make(chan domain.RunRecord, 16)
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok {
ch <- rr.Record
}
}))
return ch
}
func waitRecord(t *testing.T, ch <-chan domain.RunRecord) domain.RunRecord {
t.Helper()
select {
case r := <-ch:
return r
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for a run record")
return domain.RunRecord{}
}
}
func expectNoEntry(t *testing.T, entered <-chan int) {
t.Helper()
select {
case id := <-entered:
t.Fatalf("a job (%d) started unexpectedly", id)
case <-time.After(200 * time.Millisecond):
}
}
// TestUpdateStats verifies that aggregate statistics are folded correctly after
// a sequence of fake runs with varying durations and states.
func TestUpdateStats(t *testing.T) {
rt := &domain.JobRuntime{}
// First run: success, 200 ms.
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 200})
if rt.RunCount != 1 || rt.FailCount != 0 {
t.Fatalf("after run 1: RunCount=%d FailCount=%d, want 1/0", rt.RunCount, rt.FailCount)
}
if rt.LastDurationMS != 200 || rt.MaxDurationMS != 200 || rt.AvgDurationMS != 200 {
t.Errorf("after run 1: last=%d max=%d avg=%d, want 200/200/200",
rt.LastDurationMS, rt.MaxDurationMS, rt.AvgDurationMS)
}
// Second run: failure, 400 ms.
updateStats(rt, domain.RunRecord{State: "Failed", DurationMS: 400})
if rt.RunCount != 2 || rt.FailCount != 1 {
t.Fatalf("after run 2: RunCount=%d FailCount=%d, want 2/1", rt.RunCount, rt.FailCount)
}
if rt.LastDurationMS != 400 || rt.MaxDurationMS != 400 {
t.Errorf("after run 2: last=%d max=%d, want 400/400", rt.LastDurationMS, rt.MaxDurationMS)
}
if rt.AvgDurationMS != 300 {
t.Errorf("after run 2: avg=%d, want 300", rt.AvgDurationMS)
}
// Third run: success, 100 ms — avg should be (200+400+100)/3 = 233.
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 100})
if rt.LastDurationMS != 100 || rt.MaxDurationMS != 400 {
t.Errorf("after run 3: last=%d max=%d, want 100/400", rt.LastDurationMS, rt.MaxDurationMS)
}
if rt.AvgDurationMS != 233 {
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
}
}
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
rt := &domain.JobRuntime{}
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 200})
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 0})
if rt.RunCount != 2 {
t.Fatalf("RunCount = %d, want 2", rt.RunCount)
}
if rt.TimedRunCount != 1 {
t.Fatalf("TimedRunCount = %d, want 1", rt.TimedRunCount)
}
if rt.AvgDurationMS != 200 {
t.Errorf("AvgDurationMS = %d, want 200 (zero-duration run excluded)", rt.AvgDurationMS)
}
if rt.LastDurationMS != 200 {
t.Errorf("LastDurationMS = %d, want 200", rt.LastDurationMS)
}
}
// TestRunDueParallelStartsAllDueJobs verifies that in parallel mode every due job
// starts at once: both runs are in flight (blocked in the runner) before either
// is released.
func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
{ID: 2, Name: "B", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1, 2)
svc.RunDue(time.Now())
// Both runs must reach the runner (and block) before any is released, which is
// only possible if RunDue started them concurrently.
got := map[int]bool{}
for i := 0; i < 2; i++ {
select {
case id := <-entered:
got[id] = true
case <-time.After(2 * time.Second):
t.Fatalf("only %d job(s) started in parallel, want 2", len(got))
}
}
if !got[1] || !got[2] {
t.Fatalf("started jobs = %v, want both 1 and 2", got)
}
close(release)
waitRecord(t, done)
waitRecord(t, done)
}
// TestRunDueSequentialSerializes verifies that in sequential mode only one due job
// runs at a time: the second due job waits until the first finishes and a later
// tick picks it up.
func TestRunDueSequentialSerializes(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeSequential, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
{ID: 2, Name: "B", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1, 2)
svc.RunDue(time.Now())
// Exactly one job — the first in order — starts; the second is held back.
if id := <-entered; id != 1 {
t.Fatalf("first started job = %d, want 1", id)
}
expectNoEntry(t, entered)
// Let the first run finish, then tick again: now the second job runs.
close(release)
waitRecord(t, done)
svc.RunDue(time.Now())
if id := <-entered; id != 2 {
t.Fatalf("second started job = %d, want 2", id)
}
waitRecord(t, done)
}
// TestRunDueSkipDropsOverlap verifies that under the "skip" overlap policy a job
// coming due again while its own run is in flight does not queue or start a second
// run.
func TestRunDueSkipDropsOverlap(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// The job is now in flight; make it due again and tick. Skip must drop it.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Error("skip policy must not queue deferred runs")
}
close(release)
waitRecord(t, done)
// No re-run is queued, so the runner is invoked exactly once.
expectNoEntry(t, entered)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestRunDueQueueRerunsAfterFinish verifies that under the "queue" overlap policy a
// job coming due again while running increments PendingRuns and re-runs after the
// in-flight run finishes.
func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick: queue must increment PendingRuns without
// starting a second concurrent run.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Fatalf("queue policy must queue one deferred run, PendingRuns = %d", pending)
}
// Releasing the first run lets executeRun start the deferred run automatically.
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("re-run job = %d, want 1", id)
}
waitRecord(t, done)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
}
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("PendingRuns must be cleared after the re-run starts, got %d", pending)
}
}
// TestRunDueQueueDrainsMultipleOverlaps verifies that each missed occurrence
// under the queue policy eventually runs after the in-flight run finishes.
func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
if atomic.LoadInt32(&calls) == 0 {
<-release
}
atomic.AddInt32(&calls, 1)
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
// Three extra due ticks while the first run is still in flight.
for range 3 {
primeDue(t, svc, 1)
svc.RunDue(time.Now())
}
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 3 {
t.Fatalf("PendingRuns = %d, want 3 queued occurrences", pending)
}
close(release)
for range 4 {
waitRecord(t, done)
}
if got := atomic.LoadInt32(&calls); got != 4 {
t.Errorf("runner called %d time(s), want 4 (original + 3 queued)", got)
}
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("PendingRuns = %d after drain, want 0", pending)
}
}
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
// "queue" policy queues a re-run even though the global default is "skip": the
// effective policy is resolved per job, so the job-level value wins.
func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicyQueue)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "skip", the job's own
// "queue" policy must mark it Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Fatalf("per-job queue policy must queue a deferred run, PendingRuns = %d", pending)
}
// Releasing the first run lets executeRun start the deferred re-run.
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("re-run job = %d, want 1", id)
}
waitRecord(t, done)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
}
}
// TestRunDuePerJobSkipOverridesGlobalQueue verifies the reverse override: a job
// carrying its own "skip" policy drops an overlapping run even though the global
// default is "queue".
func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicySkip)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "queue", the job's own
// "skip" policy must drop it without marking Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("per-job skip policy must not queue deferred runs, PendingRuns = %d", pending)
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
// the job queues a re-run.
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Fatalf("empty per-job policy must inherit global queue, PendingRuns = %d", pending)
}
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
}
waitRecord(t, done)
}
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
// run is refused while another job is running, and allowed once nothing is.
func TestRunNowSequentialGuard(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeSequential, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
{ID: 2, Name: "B", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
if err := svc.RunNow(1); err != nil {
t.Fatalf("first RunNow: %v", err)
}
<-entered // job 1 is in flight
if err := svc.RunNow(2); err == nil {
t.Error("expected RunNow to be refused while another job runs (sequential mode)")
}
// Once job 1 finishes, a manual run of job 2 is allowed.
close(release)
waitRecord(t, done)
if err := svc.RunNow(2); err != nil {
t.Fatalf("RunNow after first finished: %v", err)
}
if id := <-entered; id != 2 {
t.Fatalf("second manual run job = %d, want 2", id)
}
waitRecord(t, done)
}
// TestStartRunLockedRollbackOnSaveFailure is a regression test for CODE_REVIEW
// finding #2: a run must not start when persisting the Running state fails.
func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true}})
if err := svc.store.SaveJobs(svc.jobs); err != nil {
t.Fatalf("seed jobs.json: %v", err)
}
if err := os.Chmod(svc.store.Paths.JobsPath, 0o444); err != nil {
t.Fatalf("chmod jobs.json: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
var started int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&started, 1)
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
}
if err := svc.RunNow(1); err == nil {
t.Fatal("expected RunNow to fail when jobs.json is not writable")
}
if atomic.LoadInt32(&started) != 0 {
t.Error("run goroutine must not start when SaveJobs fails")
}
if rt := svc.Runtime(1); rt == nil || rt.LastState == "Running" {
t.Errorf("runtime should roll back from Running, got %+v", rt)
}
}
// TestRunDueQueueDrainSkippedWhenPaused verifies that queued overlap runs are not
// drained while the scheduler is globally paused.
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Fatalf("expected one queued overlap, PendingRuns = %d", pending)
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 1 {
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 1", pending)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestEffectiveTimeout verifies the three-state resolution: an unset (nil)
// Job.TimeoutSeconds falls back to the global default, a positive value
// overrides it, and an explicit 0 means "no timeout" without inheriting.
func TestEffectiveTimeout(t *testing.T) {
svc := newTempService(t, nil)
svc.store.Config.DefaultTimeoutSeconds = 30
inherit := &domain.Job{TimeoutSeconds: nil}
if got, want := svc.effectiveTimeout(inherit), 30*time.Second; got != want {
t.Errorf("inherited timeout = %s, want %s", got, want)
}
own := &domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(5)}
if got, want := svc.effectiveTimeout(own), 5*time.Second; got != want {
t.Errorf("per-job timeout = %s, want %s", got, want)
}
// An explicit per-job 0 must beat a positive global default rather than be
// mistaken for "unset".
none := &domain.Job{TimeoutSeconds: domain.TimeoutSecondsPtr(0)}
if got, want := svc.effectiveTimeout(none), time.Duration(0); got != want {
t.Errorf("explicit per-job zero timeout = %s, want %s (no timeout)", got, want)
}
svc.store.Config.DefaultTimeoutSeconds = 0
if got, want := svc.effectiveTimeout(inherit), time.Duration(0); got != want {
t.Errorf("inherited timeout with no global default = %s, want %s (no timeout)", got, want)
}
}
+195
View File
@@ -0,0 +1,195 @@
package app
import (
"context"
"sync"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
"gitea.mixdep.ru/mix/gosentry/src/runner"
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
// Service is the application-service layer: the single owner of GoSentry's
// in-memory state. It holds the durable jobs slice, the transient runtime map
// keyed by Job.ID, and a reference to the store that persists them. All access
// to that state goes through a mutex so the GUI and the scheduler can no longer
// race on a shared *[]Job.
//
// Mutations live in operations.go; scheduling and run dispatch live in run.go;
// typed events live in events.go. The scheduler is a thin timing loop that calls
// RunDue on every tick and holds no job state of its own.
//
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
// it; unexported helpers ending in "Locked" assume the caller already holds it.
// The Service must never call back into the UI (or any code that might re-enter
// the Service) while holding mu — in particular emit() is always called after
// mu is released.
type Service struct {
mu sync.Mutex
store *storage.Store
jobs []domain.Job
runtimes map[int]*domain.JobRuntime
// schedules caches a parsed Schedule per job ID so timing math does not
// re-parse the schedule string on every use. paused is the global pause flag.
// Both are guarded by mu.
schedules map[int]domain.Schedule
paused bool
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
// tests with a fake so the run paths can be exercised without spawning real
// processes. ctx is the lifecycle context passed to runs; Start replaces it
// with a cancelable context so Stop can abort in-flight runs, and until Start
// it is context.Background().
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error)
ctx context.Context
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
// Both are guarded by mu.
sched *scheduler.Scheduler
cancel context.CancelFunc
// manager is the platform autostart implementation. It is nil in tests that
// do not exercise autostart; Open() wires it via autostart.New().
manager autostart.Manager
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
dispatchMu sync.Mutex
observers []Observer
}
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
// store is the Service's sole channel to persistence.
func NewService(store *storage.Store, jobs []domain.Job) *Service {
s := &Service{
store: store,
runJob: runner.RunJob,
ctx: context.Background(),
paused: store.Config.Paused,
}
// No lock is needed here: construction is single-threaded, before Start
// launches the timing loop.
s.adoptJobsLocked(jobs)
return s
}
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
// derived from it: the runtime map, the parsed-schedule cache, each job's first
// next-run — so the Service is ready to schedule the moment it exists, mirroring
// the old scheduler's reset-on-construction — and the statistics seeded from
// existing log files, so the details panel shows accumulated run history
// immediately rather than only runs since this process started.
//
// It backs both construction and a Settings change that points at a different
// jobs file. The caller must hold mu.
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.jobs = jobs
s.runtimes = domain.NewRuntimes(jobs)
s.schedules = make(map[int]domain.Schedule, len(jobs))
now := time.Now()
for index := range s.jobs {
job := &s.jobs[index]
s.parseScheduleLocked(job)
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
}
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
runtime := s.runtimes[id]
if runtime == nil {
continue
}
runtime.RunCount = seed.RunCount
runtime.FailCount = seed.FailCount
runtime.LastDurationMS = seed.LastDurationMS
runtime.AvgDurationMS = seed.AvgDurationMS
runtime.MaxDurationMS = seed.MaxDurationMS
runtime.TimedRunCount = seed.TimedRunCount
}
}
// Start begins scheduling with the real wall clock. It is the production entry
// point; tests should call StartWith and supply a fake clock instead. Start is
// expected once, during setup, before any concurrent use.
func (s *Service) Start() {
s.StartWith(scheduler.NewRealClock())
}
// StartWith begins scheduling driven by the given clock; every tick calls
// RunDue. Used by tests to inject a fake clock.
func (s *Service) StartWith(clock scheduler.Clock) {
s.mu.Lock()
ctx, cancel := context.WithCancel(context.Background())
s.ctx = ctx
s.cancel = cancel
s.sched = scheduler.NewScheduler(clock, s.RunDue)
sched := s.sched
s.mu.Unlock()
sched.Start()
}
// Stop halts scheduling and cancels the run context so in-flight runs see a
// canceled context. It is safe to call when Start was never called.
func (s *Service) Stop() {
s.mu.Lock()
sched := s.sched
cancel := s.cancel
s.mu.Unlock()
if sched != nil {
sched.Stop()
}
if cancel != nil {
cancel()
}
}
// Open loads the store and constructs a Service from it in one step. It is the
// convenience entry point for the application; tests inject a pre-built store
// via NewService instead.
func Open() (*Service, error) {
store, jobs, err := storage.OpenStore()
if err != nil {
return nil, err
}
svc := NewService(store, jobs)
svc.manager = autostart.New()
return svc, nil
}
// Store returns the underlying store. It is exposed so callers that still need
// resolved paths and config (the GUI, during the transition) can reach them;
// later phases narrow this surface.
func (s *Service) Store() *storage.Store {
return s.store
}
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
// from mutating Service-owned state behind its back: the Service stays the sole
// writer.
func (s *Service) Jobs() []domain.Job {
s.mu.Lock()
defer s.mu.Unlock()
jobs := make([]domain.Job, len(s.jobs))
copy(jobs, s.jobs)
return jobs
}
// Runtime returns the transient runtime state for a job ID, or nil if no job
// with that ID is loaded. The returned pointer is the live runtime; reads of it
// are only safe while no concurrent mutation is in flight. The UI listener
// marshals reads onto the main thread via fyne.Do.
func (s *Service) Runtime(id int) *domain.JobRuntime {
s.mu.Lock()
defer s.mu.Unlock()
return s.runtimes[id]
}
+57
View File
@@ -0,0 +1,57 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
func newTestService(jobs []domain.Job) *Service {
return NewService(&storage.Store{}, jobs)
}
func TestNewServiceBuildsRuntimePerJob(t *testing.T) {
jobs := []domain.Job{
{ID: 1, Name: "Enabled", Enabled: true},
{ID: 2, Name: "Disabled", Enabled: false},
}
svc := newTestService(jobs)
if got := svc.Runtime(1); got == nil {
t.Fatal("expected runtime for enabled job 1")
} else if got.LastState != "Ready" {
t.Errorf("enabled job runtime state = %q, want %q", got.LastState, "Ready")
}
if got := svc.Runtime(2); got == nil {
t.Fatal("expected runtime for disabled job 2")
} else if got.LastState != "Paused" {
t.Errorf("disabled job runtime state = %q, want %q", got.LastState, "Paused")
}
if got := svc.Runtime(99); got != nil {
t.Errorf("expected nil runtime for unknown job, got %+v", got)
}
}
func TestJobsReturnsCopy(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "Original"}}
svc := newTestService(jobs)
snapshot := svc.Jobs()
if len(snapshot) != 1 {
t.Fatalf("Jobs() len = %d, want 1", len(snapshot))
}
// Mutating the returned slice must not affect Service-owned state.
snapshot[0].Name = "Mutated"
if again := svc.Jobs(); again[0].Name != "Original" {
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
}
}
func TestStoreReturnsWiredStore(t *testing.T) {
store := &storage.Store{}
svc := NewService(store, nil)
if svc.Store() != store {
t.Error("Store() did not return the wired store")
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
package core
package app
// Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build.
var Version = "0.3.2"
var Version = "1.0.0"
-183
View File
@@ -1,183 +0,0 @@
//go:build linux
package core
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
const autostartDesktopFileName = "gosentry.desktop"
const legacyAutostartDesktopFileName = "pysentry.desktop"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
}
// A desktop scheduler with a tray icon belongs to the graphical session, so
// Linux autostart is implemented through XDG Autostart instead of a systemd
// user service. systemd is tempting because it is explicit and scriptable,
// but it is the wrong owner for a windowed app that should inherit the
// desktop session environment and appear in the tray predictably.
if err := cleanupLegacySystemdAutostart(); err != nil {
return err
}
if err := cleanupLegacyDesktopAutostart(); err != nil {
return err
}
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
}
if legacySystemdAutostartExists() {
return false, "Legacy systemd autostart entry still exists"
}
if legacyDesktopAutostartExists() {
return false, "Legacy desktop autostart entry still exists"
}
data, readErr := os.ReadFile(desktopPath)
if !expectedEnabled {
if os.IsNotExist(readErr) {
return true, "Autostart is off"
}
return false, "Autostart desktop entry exists while setting is off"
}
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
}
return true, "Autostart is configured"
}
func autostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil
}
func legacyAutostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", legacyAutostartDesktopFileName), nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func desktopIconLine(iconPath string) string {
if strings.TrimSpace(iconPath) == "" {
return ""
}
return "Icon=" + iconPath
}
func cleanupLegacySystemdAutostart() error {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return err
}
if _, err := os.Stat(unitPath); os.IsNotExist(err) {
return nil
}
// Older PySentry builds used a systemd user unit for autostart. The current
// GoSentry implementation uses XDG Autostart because it is a GUI/tray
// application and should be launched by the desktop session. Disable and
// remove the old unit so the two mechanisms do not fight or start duplicates.
_ = exec.Command("systemctl", "--user", "disable", "pysentry.service").Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
return nil
}
func cleanupLegacyDesktopAutostart() error {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return err
}
// The old PySentry desktop file is removed proactively instead of tolerated
// alongside the new one. Leaving both files in place would risk duplicate
// launches or confusing status diagnostics after the rename.
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func legacyDesktopAutostartExists() bool {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return false
}
_, err = os.Stat(desktopPath)
return err == nil
}
func legacySystemdAutostartExists() bool {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return false
}
_, err = os.Stat(unitPath)
return err == nil
}
func legacySystemdUnitPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "systemd", "user", "pysentry.service"), nil
}
-55
View File
@@ -1,55 +0,0 @@
//go:build linux
package core
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
}
func TestLinuxAutostartRemovesLegacyDesktopEntry(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
legacyPath, err := legacyAutostartDesktopPath()
if err != nil {
t.Fatalf("resolve legacy desktop path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
t.Fatalf("create legacy desktop directory: %v", err)
}
if err := os.WriteFile(legacyPath, []byte("[Desktop Entry]\nName=PySentry\n"), 0o644); err != nil {
t.Fatalf("write legacy desktop entry: %v", err)
}
if err := SetAutostart(true, "/opt/gosentry/gosentry", ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy desktop entry still exists or cannot be checked: %v", err)
}
}
-19
View File
@@ -1,19 +0,0 @@
//go:build !windows && !linux
package core
import "fmt"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
return false, "Autostart is not implemented for this platform"
}
-68
View File
@@ -1,68 +0,0 @@
package core
import "time"
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
// Job is the user-visible scheduled command.
//
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state.
type Job struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// nextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting
// must not persist it.
nextDue time.Time
}
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
-339
View File
@@ -1,339 +0,0 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
started := time.Now()
// Commands can hang forever if a script waits for input or a child process
// stalls. A fixed timeout is a conservative first guardrail for a desktop
// scheduler; later it can become a per-job setting without changing the
// runner contract.
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
var stdout bytes.Buffer
var stderr bytes.Buffer
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
configureHiddenWindow(command)
}
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdout.String(), stderr.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
job.LastRun = now.Format("2006-01-02 15:04:05")
job.LastState = state
job.Output = output
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
record := RunRecord{
Time: job.LastRun,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
// Keep a small in-memory history for the currently running GUI. Full command
// output is persisted to files, so retaining every past record in RAM would
// only duplicate data and make long sessions grow without bound.
job.Logs = append([]RunRecord{record}, job.Logs...)
if len(job.Logs) > 50 {
job.Logs = job.Logs[:50]
}
return record
}
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return ""
}
// The timestamp comes first so a plain directory listing is naturally sorted
// by run time. The job name is included for human scanning, but sanitized to
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return ""
}
return path
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "job"
}
var builder strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "job"
}
return result
}
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
configureHiddenWindow(command)
}
err := command.Start()
duration := time.Since(started).Round(time.Millisecond)
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func acceptedExitCode(exitCode int, successExitCodes string) bool {
for _, accepted := range parseExitCodes(successExitCodes) {
if exitCode == accepted {
return true
}
}
return false
}
func parseExitCodes(value string) []int {
value = strings.TrimSpace(value)
if value == "" {
return []int{0}
}
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
result := make([]int, 0, len(fields))
seen := map[int]bool{}
for _, field := range fields {
code, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
if len(result) == 0 {
return []int{0}
}
return result
}
func successExitCodesText(job Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Itoa(code))
}
return strings.Join(parts, ",")
}
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func logArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func formatOutput(stdout string, stderr string) string {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
if stdout == "" {
// Showing an explicit placeholder is clearer than an empty panel in the
// GUI: the user can tell that the command ran but produced no stream data.
stdout = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
-277
View File
@@ -1,277 +0,0 @@
package core
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir()
job := Job{
ID: 42,
Name: "Hello Test",
Command: echoCommand("hello from test"),
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file path")
}
if filepath.Dir(record.LogFile) != logsDir {
t.Fatalf("expected log in %q, got %q", logsDir, record.LogFile)
}
if !strings.Contains(filepath.Base(record.LogFile), "Hello_Test") {
t.Fatalf("expected job name in log filename, got %q", record.LogFile)
}
data, err := os.ReadFile(record.LogFile)
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{"trigger: Manual", "job_name: Hello Test", "hello from test"} {
if !strings.Contains(content, want) {
t.Fatalf("expected log content to contain %q, got:\n%s", want, content)
}
}
}
func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
logsDir := t.TempDir()
job := Job{
ID: 43,
Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "quoted command ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
logsDir := t.TempDir()
scriptDir := filepath.Join(t.TempDir(), "Program Files", "GoSentry Test")
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
t.Fatal(err)
}
scriptPath := filepath.Join(scriptDir, "hello.cmd")
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err)
}
job := Job{
ID: 44,
Name: "Unquoted Windows Command",
Command: scriptPath,
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "unquoted command ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows command arguments only")
}
logsDir := t.TempDir()
job := Job{
ID: 45,
Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho separate arguments ok",
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "separate arguments ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
command := `sh -c 'exit 1'`
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
ID: 46,
Name: "Accepted Exit Code",
Command: command,
SuccessExitCodes: "0,1",
}
if runtime.GOOS == "windows" {
job.Arguments = "/C\nexit /b 1"
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "OK" {
t.Fatalf("expected accepted exit code to be OK, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "accepted exit code 1") {
t.Fatalf("expected accepted exit code detail, got %q", record.Detail)
}
}
func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
command := `sh -c 'exit 1'`
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
ID: 47,
Name: "Rejected Exit Code",
Command: command,
SuccessExitCodes: "0",
}
if runtime.GOOS == "windows" {
job.Arguments = "/C\nexit /b 1"
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "Failed" {
t.Fatalf("expected rejected exit code to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "Exit code 1") {
t.Fatalf("expected exit code detail, got %q", record.Detail)
}
}
func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command := "sh"
arguments := "-c\nexit 7"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7"
}
job := Job{
ID: 48,
Name: "Start Only",
Command: command,
Arguments: arguments,
StartOnly: true,
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "OK" {
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "not waiting for process exit") {
t.Fatalf("expected start-only detail, got %q", record.Detail)
}
if !strings.Contains(record.Output, "start_only:\ntrue") {
t.Fatalf("expected start-only output, got:\n%s", record.Output)
}
}
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{
ID: 49,
Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command",
Arguments: "--force-direct-start",
StartOnly: true,
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "Failed" {
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Output, "Process did not start") {
t.Fatalf("expected start failure output, got:\n%s", record.Output)
}
}
func TestParseExitCodes(t *testing.T) {
got := parseExitCodes("0, 1;2\n3")
want := []int{0, 1, 2, 3}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("expected %v, got %v", want, got)
}
}
}
func TestDirectCommandDoesNotHideWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process",
})
if invocation.hideWindow {
t.Fatal("direct command should not request hidden startup window")
}
}
func TestShellCommandHidesWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
configureHiddenWindow(invocation.command)
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
t.Fatal("expected shell command to be hidden")
}
}
func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
configureHiddenWindow(command)
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
if command.SysProcAttr == nil {
t.Fatal("expected SysProcAttr")
}
if command.SysProcAttr.CmdLine != want {
t.Fatalf("expected command line %q, got %q", want, command.SysProcAttr.CmdLine)
}
}
func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
got := windowsShellCommandLine(`C:\Program Files\Joplin\Joplin.exe --profile "D:\Joplin Profile"`)
want := `cmd.exe /S /C ""C:\Program Files\Joplin\Joplin.exe" --profile "D:\Joplin Profile""`
if got != want {
t.Fatalf("expected command line %q, got %q", want, got)
}
}
-252
View File
@@ -1,252 +0,0 @@
package core
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
// It receives a pointer to the jobs slice because the GUI edits the same slice;
// this keeps the early architecture simple while storage and scheduling are
// still in one desktop process.
type Scheduler struct {
store *Store
jobs *[]Job
onChange func(RunRecord)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
paused bool
}
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
store: store,
jobs: jobs,
onChange: onChange,
ctx: ctx,
cancel: cancel,
}
s.resetNextRuns(time.Now())
return s
}
func (s *Scheduler) Start() {
// A one-second ticker is accurate enough for cron-style desktop automation
// and avoids the complexity of maintaining one timer per job. Five-field cron
// expressions have minute precision, while @every values may be shorter for
// testing and lightweight local tasks.
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case now := <-ticker.C:
s.tick(now)
}
}
}()
}
func (s *Scheduler) Stop() {
s.cancel()
}
func (s *Scheduler) SetPaused(paused bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.paused = paused
now := time.Now()
// Pause state is reflected into each job's display string so the list view is
// understandable even before the next scheduler tick.
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
if paused {
job.NextRun = "Scheduler paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) RunNow(index int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return false
}
// Manual runs share the same runner and log writer as scheduled runs. The
// Trigger field is the only difference, which keeps History comparable and
// prevents "Run now" from becoming a separate behavior path.
return s.startRunLocked(index, "Manual")
}
func (s *Scheduler) RefreshSchedule(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return
}
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
return
}
if s.paused {
job.NextRun = "Scheduler paused"
return
}
s.prepareNextRun(job, time.Now())
}
func (s *Scheduler) tick(now time.Time) {
var changed bool
s.mu.Lock()
if !s.paused {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
continue
}
// Run only one due job per tick for now. That avoids overlapping shell
// commands in the GUI process and keeps the first version predictable;
// a future worker pool can add concurrency once cancellation and status
// reporting are more explicit.
changed = s.startRunLocked(index, "Schedule")
break
}
}
s.mu.Unlock()
_ = changed
}
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
job := &(*s.jobs)[index]
if job.LastState == "Running" {
return false
}
jobCopy := *job
job.LastState = "Running"
job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs)
go func() {
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
s.mu.Lock()
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
current.LastRun = record.Time
current.LastState = record.State
current.Output = record.Output
current.Logs = append([]RunRecord{record}, current.Logs...)
if len(current.Logs) > 50 {
current.Logs = current.Logs[:50]
}
s.prepareNextRun(current, time.Now())
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
_ = s.store.SaveJobs(*s.jobs)
}
s.mu.Unlock()
if s.onChange != nil {
s.onChange(record)
}
}()
return true
}
func (s *Scheduler) findJobByIDLocked(id int) *Job {
for index := range *s.jobs {
if (*s.jobs)[index].ID == id {
return &(*s.jobs)[index]
}
}
return nil
}
func runningOutput(job Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
builder.WriteString("trigger:\n")
builder.WriteString(trigger + "\n\n")
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nsuccess_exit_codes:\n")
builder.WriteString(successExitCodesText(job))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
func (s *Scheduler) resetNextRuns(now time.Time) {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if schedule == "" {
return time.Time{}, false
}
if strings.HasPrefix(schedule, "@every ") {
// @every is kept alongside cron because it is convenient for quick tests
// and for simple intervals that are awkward to express as five fields.
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return time.Time{}, false
}
return from.Add(interval), true
}
// Standard five-field cron keeps GoSentry compatible with the mental model
// users already know from Unix cron, while robfig/cron handles edge cases
// such as ranges, steps, and day-of-week names.
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
}
-54
View File
@@ -1,54 +0,0 @@
package core
import (
"strings"
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{
Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
SuccessExitCodes: "0,1",
}
output := runningOutput(job, "Manual", started)
for _, want := range []string{
"Running since 2026-06-17 23:40:00",
"Manual",
job.Command,
job.Arguments,
"0,1",
"start_only",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
}
}
}
-260
View File
@@ -1,260 +0,0 @@
package core
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"go.yaml.in/yaml/v4"
)
type Store struct {
Paths Paths
Config Config
}
func OpenStore() (*Store, []Job, error) {
paths, err := ResolvePaths()
if err != nil {
return nil, nil, err
}
store := &Store{Paths: paths}
config, err := loadOrCreateConfig(paths)
if err != nil {
return nil, nil, err
}
store.Config = config
store.applyConfigPaths()
// Save the config after loading so missing defaults are written back. This
// rewrites old or hand-edited files into the current clean schema without
// forcing the user to delete them manually.
if err := store.SaveConfig(); err != nil {
return nil, nil, err
}
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
if err != nil {
return nil, nil, err
}
normalizeJobs(jobs)
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
// only durable job definitions remain, because runtime fields are tagged
// yaml:"-" in the model.
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
func (s *Store) SaveConfig() error {
s.applyConfigPaths()
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.ConfigPath, s.Config)
}
func (s *Store) SaveJobs(jobs []Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
}
func loadOrCreateConfig(paths Paths) (Config, error) {
// Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory.
config := Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
}
configPath := paths.ConfigPath
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
// The rename from PySentry to GoSentry changed the preferred config
// filename. Read the old file once if it is still present so portable
// installs continue to start without a manual migration step. The
// caller later saves the loaded config back through SaveConfig, which
// naturally rewrites it under gosentry.yaml.
configPath = legacyPath
} else {
return config, writeYAML(paths.ConfigPath, config)
}
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
return config, writeYAML(paths.ConfigPath, config)
}
data, err := os.ReadFile(configPath)
if err != nil {
return Config{}, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root
// directories. This avoids accidentally writing jobs to unexpected places.
config.JobsDir = "."
}
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
return config, nil
}
func loadOrCreateJobs(path string) ([]Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// The first run creates harmless sample jobs so a new user can immediately
// see scheduled and manual execution without inventing a command.
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
return file.Jobs, nil
}
func normalizeJobs(jobs []Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
// IDs are assigned only when absent. Existing IDs stay stable because
// History and future log associations use them to identify jobs.
job.ID = next
}
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)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
// Runtime fields are reconstructed each time the app starts. Persisted run
// records live in log files, not in jobs.yaml, to keep the jobs file easy
// to review and edit by hand.
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir)
}
func resolveConfiguredDir(appDir string, dir string) string {
if filepath.IsAbs(dir) {
return dir
}
// Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts,
// Explorer launches, and terminal launches consistent.
return filepath.Clean(filepath.Join(appDir, dir))
}
func (s *Store) applyConfigPaths() {
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
}
func writeYAML(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := yaml.Marshal(value)
if err != nil {
return err
}
// WriteFile replaces the full file instead of patching it in place. For small
// YAML files this is simpler and prevents stale keys from older versions from
// lingering after the schema changes.
return os.WriteFile(path, data, 0o644)
}
func defaultJobs() []Job {
return []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,
},
}
}
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, "'", "'\\''") + "'"
}
-38
View File
@@ -1,38 +0,0 @@
package core
import (
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package domain
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// ExecutionMode controls whether due jobs run concurrently or one at a time.
type ExecutionMode string
const (
// ExecutionModeParallel allows all due jobs to start simultaneously.
ExecutionModeParallel ExecutionMode = "parallel"
// ExecutionModeSequential runs due jobs one after another, in order.
ExecutionModeSequential ExecutionMode = "sequential"
)
// Theme selects the application's visual appearance. It is a UI-only choice with
// no effect on scheduling; it is stored in Config so it persists across launches
// alongside the other desktop-shell preferences.
type Theme string
const (
// ThemeDefault keeps Fyne's built-in theme — the original look.
ThemeDefault Theme = "default"
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
// and app icon.
ThemeGoSentry Theme = "gosentry"
)
// JobListView selects how densely the Jobs tab renders its sidebar list. Like
// Theme it is a UI-only choice with no effect on scheduling; it lives in Config
// so the user's preference survives a restart.
type JobListView string
const (
// JobListViewDetailed is the three-line row: name, metadata, status.
JobListViewDetailed JobListView = "detailed"
// JobListViewCompact is the one-line row: name on the left, status on the
// right, so many more jobs fit without scrolling.
JobListViewCompact JobListView = "compact"
)
// IsCompact reports whether the compact rendering is selected. Only the exact
// "compact" value counts, so empty, legacy, and unrecognised values all read as
// detailed — every consumer normalizes them the same way.
func (v JobListView) IsCompact() bool {
return v == JobListViewCompact
}
// OverlapPolicy decides what happens when a job's next run fires while the
// previous run is still active.
type OverlapPolicy string
const (
// OverlapPolicySkip discards the new run when the job is already running.
OverlapPolicySkip OverlapPolicy = "skip"
// OverlapPolicyQueue holds the new run and starts it as soon as the current
// run finishes.
OverlapPolicyQueue OverlapPolicy = "queue"
)
// Config is stored in gosentry.json next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
// JobsFile is the full path of the JSON file holding the job definitions,
// file name included, so the user can keep jobs under any name they like. A
// relative path is resolved against the program folder.
JobsFile string `json:"jobs_file"`
// JobsDir is the pre-0.15 setting that named only the directory, with the
// file name fixed to jobs.json. It is still read so an older gosentry.json
// keeps working: storage.loadOrCreateConfig turns it into JobsFile and
// clears it, so the field disappears from the file on the next save.
JobsDir string `json:"jobs_dir,omitempty"`
LogsDir string `json:"logs_dir"`
MaxLogFiles int `json:"max_log_files"`
MaxLogAgeDays int `json:"max_log_age_days"`
StartOnLogin bool `json:"start_on_login,omitempty"`
KeepRunningInTray bool `json:"keep_running_in_tray,omitempty"`
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
// DefaultTimeoutSeconds is the run timeout applied to jobs that leave their
// own Job.TimeoutSeconds unset. 0 (the default) means no timeout: such jobs
// run to completion however long that takes. It is written even when 0 —
// omitempty would hide a deliberate choice from the hand-editable config.
DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
Paused bool `json:"paused,omitempty"`
// Theme selects the visual appearance. Empty is treated as ThemeDefault so
// configs written before this field existed keep the original look.
Theme Theme `json:"theme,omitempty"`
// JobListView selects the Jobs list density. Empty is treated as
// JobListViewDetailed so configs written before this field existed keep the
// current three-line rows.
JobListView JobListView `json:"job_list_view,omitempty"`
}
// DefaultConfig returns the built-in default settings. It is the config used
// when gosentry.json does not yet exist, and is also what the Settings UI
// offers to restore via its "Defaults" button.
func DefaultConfig() Config {
return Config{
JobsFile: "jobs.json",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
ExecutionMode: ExecutionModeParallel,
OverlapPolicy: OverlapPolicySkip,
Theme: ThemeDefault,
JobListView: JobListViewDetailed,
DefaultTimeoutSeconds: 0,
}
}
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `json:"jobs"`
}
+30
View File
@@ -0,0 +1,30 @@
package domain
import "testing"
// TestJobListViewIsCompact pins the normalization rule: only the exact
// "compact" value selects the one-line rows, so empty and unrecognised values
// (including configs written before the field existed) keep the detailed look.
func TestJobListViewIsCompact(t *testing.T) {
cases := []struct {
view JobListView
want bool
}{
{JobListViewCompact, true},
{JobListViewDetailed, false},
{"", false},
{"Compact", false},
{"tiny", false},
}
for _, tc := range cases {
if got := tc.view.IsCompact(); got != tc.want {
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
}
}
}
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
}
}
+33
View File
@@ -0,0 +1,33 @@
package domain
// Job is the user-visible scheduled command. It contains only durable
// configuration: every field is persisted to jobs.json. Transient execution
// state (last run, next run, command output, in-memory activity) lives in a
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
// configuration and never mixes in process-lifetime bookkeeping.
type Job struct {
ID int `json:"id"`
Name string `json:"name"`
Folder string `json:"folder,omitempty"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Arguments string `json:"arguments,omitempty"`
StartOnly bool `json:"start_only,omitempty"`
Enabled bool `json:"enabled"`
OverlapPolicy string `json:"overlap_policy,omitempty"`
// TimeoutSeconds bounds how long a run may take before it is killed. It is a
// pointer so the three states stay distinguishable on disk: absent (nil)
// means "inherit the global Config.DefaultTimeoutSeconds", mirroring
// OverlapPolicy's empty string; an explicit 0 means "no timeout" and does
// not inherit; a positive value is the per-job limit in seconds. The
// inherited global default may itself be 0, also meaning no timeout.
// normalizeJobs must leave nil untouched rather than backfilling a value.
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
}
// TimeoutSecondsPtr returns a pointer suitable for Job.TimeoutSeconds. It exists
// because nil (inherit) and an explicit 0 (no timeout) are different states, so
// callers cannot just assign an int.
func TimeoutSecondsPtr(seconds int) *int {
return &seconds
}
+16
View File
@@ -0,0 +1,16 @@
package domain
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
DurationMS int64 `yaml:"duration_ms,omitempty"`
}
+67
View File
@@ -0,0 +1,67 @@
package domain
import "time"
// JobRuntime is the transient execution state for a Job. It is never written to
// jobs.json: it is rebuilt from scratch each time GoSentry starts and is held in
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate
// from Job is what lets the durable configuration file stay free of run records,
// status strings, and scheduling bookkeeping.
type JobRuntime struct {
LastRun string
NextRun string
LastState string
Output string
Logs []RunRecord
// NextDue is the next scheduled execution time, kept as time.Time for
// scheduler comparisons. NextRun above is its formatted display string and is
// the only form shown in the GUI.
NextDue time.Time
// PendingRuns counts scheduled occurrences that fired while a run was still
// in flight under the "queue" overlap policy. executeRun drains the counter
// by starting one deferred run after each completion.
PendingRuns int
// Execution-time statistics accumulated since the last process start.
// Seeded from log files on startup; zero until then.
RunCount int
FailCount int
LastDurationMS int64
AvgDurationMS int64
MaxDurationMS int64
// TimedRunCount is the number of runs that contributed to AvgDurationMS.
// Runs with no recorded duration (legacy logs, or sub-millisecond StartOnly
// launches that round to 0) increment RunCount but not this. StartOnly runs
// otherwise contribute their launch latency.
TimedRunCount int
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
// job. Enabled jobs start "Ready" and wait for the scheduler to compute their
// first run; disabled jobs start "Paused".
func NewRuntime(job Job) *JobRuntime {
runtime := &JobRuntime{
LastRun: "Never",
Output: "No command output captured yet.",
}
if job.Enabled {
runtime.LastState = "Ready"
runtime.NextRun = "After start"
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
}
return runtime
}
// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is
// the convenience entry point used when a whole jobs file has just been loaded.
func NewRuntimes(jobs []Job) map[int]*JobRuntime {
runtimes := make(map[int]*JobRuntime, len(jobs))
for _, job := range jobs {
runtimes[job.ID] = NewRuntime(job)
}
return runtimes
}
+87
View File
@@ -0,0 +1,87 @@
package domain
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// cronParser accepts standard five-field cron expressions (minute, hour, day of
// month, month, day of week) plus descriptors such as "@daily". It is the single
// source of truth for what GoSentry considers a valid cron schedule.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// everyPrefix marks the "@every <duration>" form, which is kept alongside cron
// because it is convenient for quick tests and for simple intervals that are
// awkward to express as five fields.
const everyPrefix = "@every "
// Schedule is a parsed, validated job schedule. It supports two forms:
//
// - "@every <duration>" intervals (e.g. "@every 10s"), and
// - standard five-field cron expressions (e.g. "*/5 * * * *").
//
// Parsing once and reusing the value avoids re-validating and re-parsing the
// same string on every scheduler tick. A zero Schedule is invalid; its Next
// method returns the zero time.
type Schedule struct {
raw string
every time.Duration // > 0 when the schedule is an "@every" interval
cron cron.Schedule // non-nil when the schedule is a cron expression
}
// Parse validates spec and returns a reusable Schedule. It returns an error
// describing why the schedule is unusable, which callers can surface to the user.
func Parse(spec string) (Schedule, error) {
trimmed := strings.TrimSpace(spec)
if trimmed == "" {
return Schedule{}, fmt.Errorf("schedule is empty")
}
if strings.HasPrefix(trimmed, everyPrefix) {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(trimmed, everyPrefix)))
if err != nil {
return Schedule{}, fmt.Errorf("invalid %q duration: %w", strings.TrimSpace(everyPrefix), err)
}
if interval <= 0 {
return Schedule{}, fmt.Errorf("%q duration must be positive, got %s", strings.TrimSpace(everyPrefix), interval)
}
return Schedule{raw: trimmed, every: interval}, nil
}
// robfig/cron handles edge cases such as ranges, steps, and day-of-week names,
// keeping GoSentry compatible with the mental model users know from Unix cron.
parsed, err := cronParser.Parse(trimmed)
if err != nil {
return Schedule{}, fmt.Errorf("invalid cron expression: %w", err)
}
return Schedule{raw: trimmed, cron: parsed}, nil
}
// Validate reports whether spec is a usable schedule string. It is a convenience
// wrapper around Parse for callers (such as form validation) that only need the
// yes/no answer and the error message.
func Validate(spec string) error {
_, err := Parse(spec)
return err
}
// Next returns the next time the schedule fires strictly after from. For an
// "@every" interval this is from plus the interval; for a cron expression it is
// the cron library's next matching time. A zero (unparsed) Schedule returns the
// zero time.
func (s Schedule) Next(from time.Time) time.Time {
switch {
case s.every > 0:
return from.Add(s.every)
case s.cron != nil:
return s.cron.Next(from)
default:
return time.Time{}
}
}
// String returns the original, trimmed schedule specification.
func (s Schedule) String() string {
return s.raw
}
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"testing"
"time"
)
func TestParseRejectsInvalidSchedules(t *testing.T) {
cases := []struct {
spec string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every ", "@every with trailing space but no duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
{"* * * *", "too few cron fields"},
}
for _, tc := range cases {
if _, err := Parse(tc.spec); err == nil {
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
if err := Validate(tc.spec); err == nil {
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
}
}
func TestParseEveryInterval(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse("@every 10s")
if err != nil {
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse(" @every 90m ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronExpression(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("*/5 * * * *")
if err != nil {
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronDescriptor(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("@daily")
if err != nil {
t.Fatalf("Parse(@daily): unexpected error: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestValidateAcceptsValidSchedules(t *testing.T) {
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
if err := Validate(spec); err != nil {
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
}
}
}
func TestZeroScheduleNextIsZero(t *testing.T) {
var s Schedule
if got := s.Next(time.Now()); !got.IsZero() {
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
}
}
func TestStringReturnsTrimmedSpec(t *testing.T) {
s, err := Parse(" */5 * * * * ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.String(), "*/5 * * * *"; got != want {
t.Fatalf("String: got %q, want %q", got, want)
}
}
-1057
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
package autostart
// Manager controls platform autostart for the application.
type Manager interface {
// Set writes or removes the platform autostart entry to match enabled.
Set(enabled bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled.
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
}
+102
View File
@@ -0,0 +1,102 @@
//go:build linux
package autostart
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
type linuxManager struct{}
// New returns the Linux autostart Manager.
func New() Manager { return linuxManager{} }
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
}
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
}
data, readErr := os.ReadFile(desktopPath)
if !expectedEnabled {
if os.IsNotExist(readErr) {
return true, "Autostart is off"
}
return false, "Autostart desktop entry exists while setting is off"
}
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
}
return true, "Autostart is configured"
}
func autostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func desktopIconLine(iconPath string) string {
if strings.TrimSpace(iconPath) == "" {
return ""
}
return "Icon=" + iconPath
}
@@ -0,0 +1,35 @@
//go:build linux
package autostart
import (
"os"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !windows && !linux
package autostart
import "fmt"
type otherManager struct{}
// New returns the stub autostart Manager for unsupported platforms.
func New() Manager { return otherManager{} }
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
return false, "Autostart is not implemented for this platform"
}
@@ -1,4 +1,4 @@
package core
package autostart
import (
"fmt"
@@ -6,10 +6,25 @@ import (
"os/exec"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
type windowsManager struct{}
// New returns the Windows autostart Manager.
func New() Manager { return windowsManager{} }
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
@@ -18,10 +33,6 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
// the picture. A Startup-folder shortcut stores target path and arguments as
// separate structured fields, so it avoids quoting bugs and more closely
// matches how a user would configure a GUI app by hand.
if err := cleanupLegacyRegistryAutostart(); err != nil {
return err
}
shortcutPath, err := startupShortcutPath()
if err != nil {
return err
@@ -41,9 +52,6 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
_, statErr := os.Stat(shortcutPath)
if !expectedEnabled {
if os.IsNotExist(statErr) {
if legacyRegistryAutostartExists() {
return false, "Legacy registry autostart exists; save settings to repair"
}
return true, "Autostart is off"
}
if statErr != nil {
@@ -53,9 +61,6 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
}
if os.IsNotExist(statErr) {
if legacyRegistryAutostartExists() {
return false, "Legacy registry autostart exists; save settings to repair"
}
return false, "Autostart shortcut is missing"
}
if statErr != nil {
@@ -69,7 +74,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != StartInTrayArgument {
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
}
return true, "Autostart is configured"
@@ -101,11 +106,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
@@ -123,7 +128,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
output, err := command.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
@@ -137,11 +142,6 @@ func readShortcut(shortcutPath string) (string, string, error) {
return target, arguments, nil
}
func readShortcutTarget(shortcutPath string) (string, error) {
target, _, err := readShortcut(shortcutPath)
return target, err
}
func removeIfExists(path string) error {
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
@@ -150,40 +150,6 @@ func removeIfExists(path string) error {
return err
}
func cleanupLegacyRegistryAutostart() error {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
configureHiddenWindow(command)
_ = command.Run()
}
return nil
}
func legacyRegistryAutostartExists() bool {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
configureHiddenWindow(command)
if command.Run() == nil {
return true
}
}
return false
}
func parseRegistryRunValue(output string) (string, bool) {
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
for index, field := range fields {
if field == "REG_SZ" && index+1 < len(fields) {
value := strings.Join(fields[index+1:], " ")
value = strings.Trim(value, `"`)
return value, value != ""
}
}
}
return "", false
}
func sameWindowsPath(left string, right string) bool {
left = normalizeWindowsPath(left)
right = normalizeWindowsPath(right)
@@ -1,27 +1,15 @@
//go:build windows
package core
package autostart
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestParseRegistryRunValue(t *testing.T) {
output := `
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
GoSentry REG_SZ "D:\Apps\GoSentry\gosentry.exe"
`
value, ok := parseRegistryRunValue(output)
if !ok {
t.Fatal("expected registry value to parse")
}
if value != `D:\Apps\GoSentry\gosentry.exe` {
t.Fatalf("unexpected value: %q", value)
}
}
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
@@ -111,8 +99,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
@@ -138,7 +126,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
@@ -1,11 +1,12 @@
//go:build linux
package core
package desktop
import (
"fmt"
"os"
"path/filepath"
"strconv"
)
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
@@ -40,6 +41,10 @@ StartupWMClass=%s
return iconPath, nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func xdgDataHome() (string, error) {
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
@@ -0,0 +1,62 @@
//go:build linux
package desktop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallDesktopIntegrationWritesDesktopAndIcon(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
appID := "ru.mixeme.gosentry.desktop"
executable := filepath.Join(dataHome, "bin", "gosentry")
icon := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix is enough for file presence
iconPath, err := InstallDesktopIntegration(appID, executable, icon)
if err != nil {
t.Fatalf("InstallDesktopIntegration: %v", err)
}
if _, err := os.Stat(iconPath); err != nil {
t.Fatalf("icon file: %v", err)
}
iconData, err := os.ReadFile(iconPath)
if err != nil {
t.Fatalf("read icon: %v", err)
}
if string(iconData) != string(icon) {
t.Fatalf("icon bytes mismatch")
}
desktopPath := filepath.Join(dataHome, "applications", appID+".desktop")
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
text := string(data)
if !strings.Contains(text, "Name=GoSentry") {
t.Fatalf("desktop entry missing Name: %s", text)
}
if !strings.Contains(text, "StartupWMClass="+appID) {
t.Fatalf("desktop entry missing WM class: %s", text)
}
wantExec := "Exec=" + quoteDesktopExec(executable)
if !strings.Contains(text, wantExec) {
t.Fatalf("desktop entry exec = %s, want substring %q", text, wantExec)
}
if !strings.Contains(text, "Icon="+iconPath) {
t.Fatalf("desktop entry missing Icon path: %s", text)
}
}
func TestQuoteDesktopExecQuotesPath(t *testing.T) {
got := quoteDesktopExec("/opt/Go Sentry/gosentry")
if got != `"/opt/Go Sentry/gosentry"` {
t.Errorf("quoteDesktopExec = %q", got)
}
}
@@ -1,6 +1,6 @@
//go:build !linux
package core
package desktop
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
return "", nil
+42
View File
@@ -0,0 +1,42 @@
// Package filemanager opens a directory in the desktop file manager, so the
// UI can reveal a configured folder (logs, jobs) without knowing which handler
// the platform uses.
package filemanager
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
)
// Open shows dir in the platform file manager. A missing path, a path that is
// not a directory, and a handler that fails to start are all returned as
// errors so the caller can surface them instead of appearing to do nothing.
func Open(dir string) error {
info, err := os.Stat(dir)
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("folder does not exist: %s", dir)
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("not a folder: %s", dir)
}
name, args := openCommand(dir)
if name == "" {
return fmt.Errorf("opening a folder is not supported on %s", runtime.GOOS)
}
command := exec.Command(name, args...)
if err := command.Start(); err != nil {
return err
}
// The handler hands the request to the desktop shell and exits on its own —
// Windows Explorer even exits non-zero after opening the window — so its
// status carries no information. Wait runs only to release the process
// handle, and never blocks the caller.
go func() { _ = command.Wait() }()
return nil
}
@@ -0,0 +1,9 @@
//go:build linux
package filemanager
// openCommand returns the XDG invocation for dir. xdg-open picks whichever
// file manager the desktop environment has registered for directories.
func openCommand(dir string) (string, []string) {
return "xdg-open", []string{dir}
}
@@ -0,0 +1,10 @@
//go:build !windows && !linux
package filemanager
// openCommand has no handler to name on platforms GoSentry does not ship for.
// An empty name makes Open report that the action is unavailable instead of
// running something arbitrary.
func openCommand(dir string) (string, []string) {
return "", nil
}
@@ -0,0 +1,67 @@
package filemanager
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The success path is deliberately not tested: it would pop a real file
// manager window on the machine running the suite. Only the guards that keep
// Open from launching anything are exercised here.
func TestOpenRejectsMissingFolder(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-such-folder")
err := Open(missing)
if err == nil {
t.Fatal("Open on a missing folder returned nil, want an error")
}
if !strings.Contains(err.Error(), missing) {
t.Errorf("error %q does not name the missing folder %q", err, missing)
}
}
func TestOpenRejectsFile(t *testing.T) {
file := filepath.Join(t.TempDir(), "gosentry.log")
if err := os.WriteFile(file, []byte("log"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
err := Open(file)
if err == nil {
t.Fatal("Open on a file returned nil, want an error")
}
if !strings.Contains(err.Error(), "not a folder") {
t.Errorf("error %q does not report that the path is not a folder", err)
}
}
// TestOpenCommandNamesPlatformHandler checks the supported platforms name a
// handler (an empty name makes Open report the action as unavailable) and that
// the directory is passed as a single argument, so spaces need no quoting.
func TestOpenCommandNamesPlatformHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "log files")
name, args := openCommand(dir)
switch runtime.GOOS {
case "windows":
if name != "explorer" {
t.Errorf("handler on windows = %q, want %q", name, "explorer")
}
case "linux":
if name != "xdg-open" {
t.Errorf("handler on linux = %q, want %q", name, "xdg-open")
}
default:
if name != "" {
t.Errorf("handler on %s = %q, want no handler", runtime.GOOS, name)
}
return
}
if len(args) != 1 || args[0] != filepath.Clean(dir) {
t.Errorf("arguments = %q, want the single path %q", args, filepath.Clean(dir))
}
}
@@ -0,0 +1,10 @@
package filemanager
import "path/filepath"
// openCommand returns the Explorer invocation for dir. The path is cleaned
// because Explorer ignores an argument that mixes separators, and it is passed
// as a single argument so spaces need no quoting.
func openCommand(dir string) (string, []string) {
return "explorer", []string{filepath.Clean(dir)}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package winproc
import "os/exec"
// ConfigureHiddenWindow is a no-op on non-Windows platforms: launching sh -c
// from a desktop process does not create a new console window in the same way
// Windows does.
func ConfigureHiddenWindow(command *exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package winproc
import (
"os/exec"
"syscall"
)
// ConfigureHiddenWindow suppresses the console window that Windows would
// otherwise flash when running a child process from a GUI application.
// CREATE_NO_WINDOW keeps cmd.exe and simple console tools quiet while
// stdout/stderr are still captured through pipes.
func ConfigureHiddenWindow(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
}
+61
View File
@@ -0,0 +1,61 @@
package runner
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More