Compare commits

39 Commits

Author SHA1 Message Date
mixeme 93b57979ec Mark T4.6 complete in REFACTORING.md
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-21 23:48:48 +03:00
mixeme b46e4ed7fa T4.6: Extract tray.go, singleinstance.go, layout.go
Extract three focused modules from run.go and mainwindow.go:

- tray.go: configureSystemTray() and related tray setup
- singleinstance.go: acquireSingleInstance(), serveSingleInstance(), and constants
- layout.go: minWidthLayout type and methods

No behavior change; purely mechanical extraction to improve file organization.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-21 23:44:39 +03:00
mixeme 832b3dcc68 T4.5: Extract settings_view.go
Move settingsView and all its helpers (fyneVersion, mustParseURL,
chooseFolder, settingsRow, settingsRowWithStatus) plus their constants
out of mainwindow.go into the new settings_view.go. Save/autostart
errors were already surfaced to the status label; no logic change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:41:12 +03:00
mixeme fb12fab71d T4.4: Extract history_view.go
Move newHistoryView, historyCellText, logFileName, newEvent, and
collectActivity out of mainwindow.go into src/ui/history_view.go.
Drop the now-unused "sort" import from mainwindow.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:30:21 +03:00
mixeme 91679efa3c T4.3: Extract job_dialog.go; validate schedule via domain.Validate
Moves showJobDialog out of jobs_view.go into its own file and adds a
domain.Validate call on the schedule field so malformed cron expressions
are rejected with an error dialog before onSave is invoked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:19:00 +03:00
mixeme 760700f6ac T4.2: Extract jobs_view.go (list + details + toolbar)
Move the jobs list, details panel, folder filter, toolbar buttons, and
all related helpers out of mainwindow.go into the new jobs_view.go.
newMainView now calls newJobsView(w, svc) which returns a panel and a
refresh closure; the subscriber and history wiring remain in mainwindow.go.

showJobDialog lives in jobs_view.go as a temporary home until T4.3
extracts it to job_dialog.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:07:34 +03:00
mixeme f28df5e77b docs: plan tray click via Fyne 2.7 SetSystemTrayWindow
Fyne 2.7.0 adds desktop.App.SetSystemTrayWindow, which shows the window
on left-click and unblocks the deferred Tray Interaction item. Record the
2.6.3 -> 2.7.x upgrade dependency and the concrete wiring in run.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:09:55 +03:00
mixeme f82eca8777 T4.1: Rename gui->ui, split lifecycle into run.go + mainwindow.go
Carve src/gui/app.go into the new src/ui package:
- run.go: process lifecycle (single instance, app/window, tray, startup
  timing).
- mainwindow.go: view assembly + the app.Service event listener.

Route every widget update driven by Service events through fyne.Do so the
run goroutine (executeRun) no longer mutates Fyne widgets directly. Also
wrap serveSingleInstance's Show/RequestFocus, which runs on the Accept
goroutine. (Resolves refactoring problem #4.)

fyne.Do/DoAndWait only exist in Fyne v2.6+, so upgrade fyne.io/fyne/v2
v2.5.3 -> v2.6.3. Mark the tray Quit item IsQuit so Fyne's
addMissingQuitForMenu reuses it instead of appending a second, localized
Quit now that v2.6 ships Russian translations.

go build / go vet / go test -race all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:55:23 +03:00
mixeme c5e0ef9617 Bump version to 0.3.5: Phase 3 service and state management refactoring complete
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-19 08:36:12 +03:00
mixeme 6754d0405b Merge branch 'docs/refactoring-plan' of https://gitea.mixdep.ru/mix/gosentry into docs/refactoring-plan 2026-06-19 08:34:08 +03:00
mixeme 6073d5e112 T3.6: Add src/app unit tests (no Fyne)
Fill the test gaps in the app package, raising coverage from 82% to 97%:

- format_test.go: cover all display helpers moved in T3.5 (StatusText,
  EventText, Display*), previously untested.
- operations_test.go: add RunNow not-found and refused-while-running
  paths, DeleteJob/SetEnabled not-found errors, UpdateJob re-enable,
  runtimeForLocked lazy-recreate, UpdateSettings invalid-config branches,
  and prependLog activity-list capping.

All tests are Fyne-free, using a temp-dir store, a fake runner seam, and
a fake Clock. go vet and go test -race pass across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:31:04 +03:00
mixeme cf2af1d464 @
T3.6: Add src/app unit tests (no Fyne)

Fill the test gaps in the app package, raising coverage from 82% to 97%:

- format_test.go: cover all display helpers moved in T3.5 (StatusText,
  EventText, Display*), previously untested.
- operations_test.go: add RunNow not-found and refused-while-running
  paths, DeleteJob/SetEnabled not-found errors, UpdateJob re-enable,
  runtimeForLocked lazy-recreate, UpdateSettings invalid-config branches,
  and prependLog activity-list capping.

All tests are Fyne-free, using a temp-dir store, a fake runner seam, and
a fake Clock. go vet and go test -race pass across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-06-19 08:30:42 +03:00
mixeme 6e5c42fbed T3.5: Move display helpers to src/app/format.go
Extract pure display/formatting functions from the GUI:
- StatusText, EventText, DisplayFolder, DisplayArguments,
  DisplaySuccessExitCodes, DisplayRunMode, DisplayInvocation, DisplayIndex.

These have no Fyne dependencies and serve as a shared formatting layer for
the service to use (e.g. StatusText in the tests when mocking the GUI).
The GUI imports them as app.DisplayFolder(...) etc.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-19 08:25:37 +03:00
mixeme a4c93a5122 T3.4: Convert scheduler to drive app.Service; inject Clock
The scheduler no longer shares a *[]domain.Job with the GUI. It is now a
thin timing loop with an injected Clock that calls a tick callback; the
application service is the sole writer of job and runtime state.

- scheduler: add Clock interface + RealClock (clock.go); strip all job
  logic from scheduler.go (NewScheduler(clock, tick)); rewrite tests to
  cover the loop with a fake clock.
- app.Service: add RunDue(now) (pause + one-run-per-tick policy, records
  back through the service) and Start(Clock)/Stop() owning a cancelable
  run context; prime each job's first next-run at construction. Capture
  the run context under the lock for executeRun.
- gui: talk only to app.Service (no shared state) — Open() the service,
  keep a refreshed snapshot, route every mutation through the service,
  and react to changes via a single Subscribe listener.
- Tests: add RunDue (due/not-due/paused) and Start-drives-RunDue cases.

Verified with CGO + MSYS2 UCRT64: go vet ./... clean, go test -race
./... green (GUI included), full module builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:22:35 +03:00
mixeme d8ab9acf7e T3.3: Add state-mutating operations to app.Service
Add src/app/operations.go with the seven intents that make the Service
the sole writer of job and runtime state: CreateJob, UpdateJob,
DeleteJob, SetEnabled, RunNow, SetGlobalPause, UpdateSettings. Each
returns error, persists through the store, and announces changes via
RunRecorded/JobChanged/SchedulerStateChanged events.

Extend the Service with a parsed-schedule cache, a global paused flag,
an injectable runJob seam (defaults to runner.RunJob) for testing the
run-now path, and a lifecycle ctx. Run and next-run timing now live in
the Service (duplicating the scheduler temporarily); T3.4 converts the
scheduler to drive the Service and removes the duplication.

Autostart is left to the caller until T5.2's injectable Manager; async
save errors in the run goroutine remain deferred to T5.1. Adds 12 tests
covering create/update/delete, enable/pause, global pause, run-now with
a fake runner, and settings persistence/validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:48:41 +03:00
mixeme 5e51381b7a T3.2: Add app event types and observer dispatch
Add src/app/events.go: a sealed Event interface with three concrete
types (JobChanged, RunRecorded, SchedulerStateChanged), an Observer
interface plus ObserverFunc adapter, and Subscribe/emit on the Service.
This replaces the scheduler's single onChange callback with typed
events the UI can exhaustively type-switch over.

Dispatch is serialized by a dedicated dispatchMu (separate from the
state lock): observers never run concurrently, emit must be called
without holding s.mu so observers can read Service state, and observers
must not re-enter an emitting method. emit is wired to mutating ops in
T3.3. Adds tests for ordered multi-observer delivery, empty-observer
no-op, and observer-reads-state-without-deadlock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:39:34 +03:00
mixeme 9931ec1237 T3.1: Add app.Service owning state behind a mutex
Create src/app/service.go: the application-service layer that becomes
the single owner of the durable jobs slice and the transient runtime
map, guarded by a non-reentrant sync.Mutex. NewService wires a loaded
store; Open() is the convenience entry point. Read-only accessors
(Jobs/Runtime/Store) take the lock, and Jobs() returns a copy to keep
callers from mutating Service-owned state.

State-mutating intents and the event/observer machinery are deferred to
T3.2-T3.4. Adds no-Fyne unit tests for runtime construction, copy
isolation, and store wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:34 +03:00
mixeme 98f692658a Bump version to 0.3.4: Phase 2 domain cleanup complete
- Split durable Job from transient JobRuntime
- Extracted Schedule value object
- Made RunJob pure (no job mutation)
- Simplified runtime initialization

No observable behavior changes.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-19 00:10:41 +03:00
mixeme b1874845d5 T2.3: Split domain.Job (durable) from domain.JobRuntime (transient)
Move all transient execution state off domain.Job into a new
domain.JobRuntime, keyed by job ID:

- domain: Job now holds only durable YAML fields; remove the yaml:"-"
  fields (LastRun/NextRun/LastState/Logs/Output) and NextDue. Add
  runtime.go with JobRuntime plus NewRuntime/NewRuntimes constructors,
  which now own the runtime-init logic moved out of normalizeJobs.
- runner: RunJob no longer mutates the job; it is pure and returns the
  RunRecord for the caller to fold into the runtime.
- scheduler: take a shared map[int]*JobRuntime and route status/next-run
  bookkeeping through runtimeFor(job); prepareNextRun writes a *JobRuntime.
- storage: normalizeJobs touches only durable config.
- gui: own the runtime map (NewRuntimes), share it with the scheduler,
  and read/write runtime state via runtimeFor; maintain the map by ID on
  add/edit/delete.
- tests: update scheduler/storage tests to the split; tidy a pre-existing
  import-order nit in scheduler.go.

This also satisfies T2.4 (storage load/save only Job, runtime init in
domain.NewRuntime, round-trip tests), since removing the fields forced it.
Runtime-map ownership remains GUI-side glue until T3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:08:05 +03:00
mixeme ca673f08f9 T2.2: Migrate scheduler to use domain.Schedule
Parse each job's schedule once on load (resetNextRuns) and on edit
(RefreshSchedule) via the new parseJobSchedule helper, caching the
result in a map[int]domain.Schedule keyed by job ID. prepareNextRun
now looks up the cached Schedule instead of re-parsing the string on
every call. Remove the nextRunTime wrapper that did the per-call
parsing. Drop the three scheduler_test.go tests that duplicated
coverage already in domain/schedule_test.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:59:46 +03:00
mixeme 0f17782174 T2.1: Add domain.Schedule value object
Introduce src/domain/schedule.go with a Schedule value object that
centralizes schedule parsing and validation: Parse, Validate, and
Next(time.Time). It owns the cron parser and @every handling, moved out
of the scheduler. The scheduler's nextRunTime is kept as a thin wrapper
delegating to domain.Parse for now (T2.2 will parse once on load/edit).

Add unit tests covering invalid specs, @every intervals, five-field cron,
cron descriptors, whitespace trimming, the zero-value Next, and String.

Mark T2.1 complete in REFACTORING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:55:20 +03:00
mixeme 9938164c7a Update changelog for 0.3.3: Phase 1 refactoring complete 2026-06-18 22:42:42 +03:00
mixeme 80b6892266 Bump version to 0.3.3 2026-06-18 22:41:25 +03:00
mixeme 29c973464c Mark T1.8 complete in REFACTORING.md 2026-06-18 22:40:45 +03:00
mixeme 2f5c920c3e T1.8: Delete empty src/core; move version to src/app
Move version.go from src/core to src/app to prepare for deleting the
now-empty src/core package. Update all imports and build script
references to use src/app.Version instead of src/core.Version.

Fix import aliasing conflict between Fyne's desktop package and our
platform/desktop package by aliasing Fyne imports as fyneapp and
fynedesktop.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 22:40:34 +03:00
mixeme 17c5e15b63 Mark T1.7 complete in REFACTORING.md 2026-06-18 22:38:03 +03:00
mixeme 794ed8061c T1.7: Create src/platform/desktop; move desktop integration
Move desktop_linux.go and desktop_other.go from src/core to the new
src/platform/desktop package. Update src/gui to import and use the
new package location.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 22:37:47 +03:00
mixeme 16d818d03d T1.6: Create src/platform/autostart; move autostart logic
Move autostart_*.go and tests from src/core to src/platform/autostart.
Update src/gui/app.go to call autostart.SetAutostart / autostart.AutostartStatus.
Add quoteDesktopExec to desktop_linux.go (was co-located in autostart_linux.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:35:51 +03:00
mixeme 06edbfff77 T1.5: Create src/storage; move store/paths logic
Move store.go, paths.go, and store_test.go from src/core into the new
src/storage package. Update src/scheduler and src/gui to import storage
instead of core for Store/Paths/OpenStore. Empty the moved files in core
to preserve the package declaration for the remaining core symbols.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:15:40 +03:00
mixeme ad0e45a7dd T1.4: Create src/scheduler; move scheduler logic
Move scheduler.go and scheduler_test.go from src/core to the new
src/scheduler package. The scheduler still takes *[]domain.Job and
*core.Store (storage moves in T1.5). Update src/gui/app.go to import
the new package; rename the local variable to sched to avoid shadowing
the scheduler package name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:03:16 +03:00
mixeme c29d581fb1 T1.3: Create src/runner; move runner logic
Move runner.go, runner_windows.go, runner_other.go, runner_test.go from
src/core to src/runner (package runner). Split helpers into focused files:
invocation*.go, exitcodes.go, logfile.go, cleanup.go. Export LogArguments
and SuccessExitCodesText for use by scheduler's runningOutput helper.
Update src/core/scheduler.go and src/gui/app.go to import src/runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:47:29 +03:00
mixeme f4fb16c0ed T1.2: Create src/platform/winproc; move configureHiddenWindow
Extract the hidden-window logic out of src/core runner and autostart files
into a new platform/winproc package with per-OS build-tag files. All call
sites updated to use winproc.ConfigureHiddenWindow. Builds clean on both
Windows and Linux; all tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:28:25 +03:00
mixeme 80c76a0cba T1.1: Create src/domain; move Job/RunRecord/Config/JobsFile/StartInTrayArgument
Extracts the five domain types out of src/core/model.go into a new
src/domain package (job.go, record.go, config.go). The unexported
nextDue field is promoted to NextDue so it is accessible from core.
All references across src/core, src/gui, and cmd/gosentry are updated
to use domain.TypeName. src/core/model.go is reduced to a bare package
declaration. Windows and Linux cross-compilation both pass; all tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:18:57 +03:00
mixeme 462752f995 Mark T0.2 as complete 2026-06-18 20:25:39 +03:00
mixeme ef6902d65c T0.2: Add characterization tests at refactoring seams
Pin current behavior at the three seams that will move during refactoring:

Store (store_test.go):
- TestJobsRoundTrip: all durable Job fields survive a writeYAML→loadOrCreateJobs
  cycle; runtime fields (LastRun, LastState, Logs) do not.
- TestConfigRoundTrip: all Config fields survive a writeYAML→loadOrCreateConfig
  cycle, including non-default booleans and custom dirs.
- TestNormalizeJobsFillsDefaults: blank jobs get default name/schedule/exitcodes
  and the correct LastState/NextRun for enabled vs disabled.

Scheduler (scheduler_test.go):
- TestNextRunTimeRejectsInvalidSchedules: empty, whitespace, bare @every,
  invalid/negative/zero durations, invalid cron, out-of-range minute all return false.
- TestPrepareNextRunSetsDisplayString: valid schedule writes NextRun as
  "YYYY-MM-DD HH:MM:SS" and sets nextDue to the matching time.Time.
- TestPrepareNextRunSetsInvalidScheduleLabel: bad schedule writes "Invalid
  schedule" and zeroes nextDue.

Runner (runner_test.go):
- TestRunJobLogFileAllHeaders: all log header fields are present (job_id,
  job_name, trigger, state, detail, command, arguments, success_exit_codes,
  start_only, stdout, stderr) and time parses as 2006-01-02 15:04:05.
- TestRunJobRecordFields: RunRecord matches the job and trigger; Time parses;
  Output contains stdout/stderr sections.
- TestFormatOutput / TestFormatOutputEmptyStreams: stdout/stderr sections are
  separated by a blank line; empty streams show "<empty>".
- TestLogArguments: empty/whitespace → "<empty>"; CRLF → LF normalised.
- TestSanitizeFileName: special chars → "_"; empty or all-special → "job".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:25:27 +03:00
mixeme 520a7ef98b Mark T0.1 as complete 2026-06-18 20:14:06 +03:00
mixeme 0038975adc T0.1: Add test scripts and documentation
Add scripts/test.sh and scripts/test.bat to run go vet and go test -race.
Update docs/TESTS.md with test script usage and reorganized manual test commands.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 20:13:46 +03:00
mixeme f653b1e484 Add task completion checklist to refactoring plan
Track the 30 tasks across 5 phases with checkboxes. Each checkbox can be
marked complete as tasks land and pass review.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 08:19:02 +03:00
mixeme 4c49104cce Add refactoring plan document
Document a phased plan to restructure GoSentry into focused packages
under src/ (domain, storage, runner, scheduler, platform, app, ui) with
an application-service layer that owns state, and link it from the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:16:03 +03:00
123 changed files with 2295 additions and 11815 deletions
+3 -2
View File
@@ -2,6 +2,7 @@
bin
dist
logs
gosentry.json
jobs.json
gosentry.yaml
pysentry.yaml
jobs.yaml
*.exe
-108
View File
@@ -1,108 +0,0 @@
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
@@ -1,8 +0,0 @@
# 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
@@ -1,96 +0,0 @@
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
+3 -9
View File
@@ -1,9 +1,6 @@
# 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
@@ -12,18 +9,15 @@ cmd/gosentry/*.syso
*.test
# Runtime files created next to the executable during local runs.
gosentry.json
jobs.json
gosentry.yaml
pysentry.yaml
jobs.yaml
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_/
-54
View File
@@ -1,54 +0,0 @@
# 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, event flow, and
§Platform layer (why and where OS-specific code lives).
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
## 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.
+340 -272
View File
@@ -1,278 +1,179 @@
<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. It provides a native GUI for
creating, grouping, pausing, running, and monitoring scheduled shell commands.
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.
## Screenshots
GoSentry is being designed and implemented with assistance from OpenAI Codex.
<table>
<tr>
<td align="center"><img src="docs/screenshots/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
<td align="center"><img src="docs/screenshots/screenshot_history.PNG" alt="History tab"><br><em>History tab — past runs with trigger, state, and log file.</em></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="docs/screenshots/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
</tr>
</table>
Project notes:
- [Changelog](docs/CHANGELOG.md)
- [Roadmap](docs/ROADMAP.md)
- [Architecture](docs/ARCHITECTURE.md)
- [Refactoring plan](docs/REFACTORING.md)
## Features
- Native desktop GUI built with [Fyne](https://fyne.io/).
- Job definitions stored in a clean, hand-editable `jobs.json`.
- `@every` intervals and standard 5-field cron expressions.
- Job storage in one clean YAML file.
- App settings in a separate YAML file.
- `@every` schedules and standard 5-field cron expressions.
- Manual and scheduled command runs.
- 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.
- Per-run `.log` files with stdout/stderr.
- Log cleanup by maximum file count and maximum age.
- 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 system or branded theme; both are remembered.
- Global pause/resume for all job execution.
- Windows tray support.
- Version shown in the window title, Settings, and build artifact names.
## Platforms
## Requirements
GoSentry is built and tested on **Windows** and **Linux**:
Common:
| 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. |
## Documentation
- [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
- [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 stores its files next to the executable by default, making it a
portable application: moving the program folder also moves its configuration.
`gosentry.json` stores application settings:
```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": "gosentry",
"job_list_view": "detailed"
}
```
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 `system` or `gosentry` (the branded teal/amber look), and
`job_list_view` is `detailed` or `compact` — both are remembered from the
choices made in the app. Keys left at their off value (`start_on_login`,
`paused`) are omitted until they are turned on.
`jobs.json` stores job definitions:
```json
{
"jobs": [
{
"id": 1,
"name": "Hello scheduler",
"folder": "Examples",
"schedule": "@every 1m",
"command": "echo GoSentry test job: scheduler is alive",
"enabled": true
}
]
}
```
`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
20260614-224306_Hello_scheduler.log
```
## Schedules
GoSentry accepts two schedule forms: fixed `@every` intervals and standard
5-field cron expressions.
### `@every` intervals
Write `@every` followed by a [Go duration](https://pkg.go.dev/time#ParseDuration)
— a positive number with a unit suffix. Units can be combined in one value:
```text
@every 10s every 10 seconds
@every 5m every 5 minutes
@every 1h every hour
@every 1h30m every hour and a half (same as @every 90m)
@every 2h45m10s hours, minutes, and seconds combined
```
Supported units:
| Unit | Meaning |
|------|---------|
| `ns` | nanoseconds |
| `us`, `µs` | microseconds |
| `ms` | milliseconds |
| `s` | seconds |
| `m` | minutes |
| `h` | hours |
`@every` does **not** support days, weeks, months, or years — those follow a
calendar, not a fixed interval. For “every day at 02:00”, “on the 1st of each
month”, or “once a year”, use a cron expression (below).
The scheduler checks due jobs once per second, so values shorter than `1s` are
accepted but will not fire faster than once a second.
### Cron expressions
Five fields: minute, hour, day-of-month, month, day-of-week.
```text
*/5 * * * * every five minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30
0 0 1 * * first day of every month at midnight
0 0 1 1 * every year on 1 January at midnight
```
Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
`@monthly`, `@yearly` (and `@annually`, `@midnight`).
## Using The App
1. Start GoSentry.
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 **Disable auto** as a global stop switch for all scheduled runs.
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
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 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.
When **Keep running in the system tray** is also enabled, the entry includes
`--start-in-tray` so scheduled jobs run after sign-in without opening the main
window. With the tray option off, autostart still works but opens the main
window normally. Changing the tray setting updates close behaviour and the
autostart entry immediately; the tray icon itself updates only after you restart
GoSentry (a Fyne limitation — see [docs/ROADMAP.md](docs/ROADMAP.md)).
## Queue Settings
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 ends in the `Failed` state —
a non-zero exit code, a timeout, or a process that failed to start.
The notification shows the job name and the failure detail.
## Autostart
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.
~/.config/autostart/gosentry.desktop
[Desktop Entry]
Type=Application
Name=GoSentry
Exec=/opt/gosentry/gosentry-<version>-linux-amd64 --start-in-tray
Terminal=false
```
- [Go](https://go.dev/) 1.22 or newer.
Windows:
- 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
# 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
# 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
@@ -280,38 +181,205 @@ Windows:
### 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 accessed through
RDP inside VirtualBox, the available video driver can fail OpenGL initialization.
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). 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.
[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`:
GoSentry `.exe`, for example:
```text
dist\windows\
gosentry-<version>-windows-amd64.exe
gosentry-0.3.0-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.
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.
## Development assistance
## Storage
Parts of this project were developed with assistance from [Cursor](https://cursor.com/) AI (Composer agent)
and [Claude Code](https://claude.com/claude-code).
GoSentry creates its runtime files next to the executable by default.
`gosentry.yaml` 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
```
`jobs.yaml` stores only job definitions:
```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
# 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
```
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
```text
# Format: YYYYMMDD-HHMMSS_<sanitized job name>.log
20260614-224306_Hello_scheduler.log
```
## Schedules
Fast interval schedules:
```text
# Go duration syntax after @every; useful for tests and simple intervals.
@every 10s
@every 5m
@every 1h30m
```
Standard 5-field cron schedules:
```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
```
## 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.
Changing `jobs_dir` saves the current job list to the new directory.
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.
## Autostart
GoSentry is a user desktop application, not a system daemon, so autostart should be 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
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.
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk
```
## Project Layout
- `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.
Build outputs are written to `dist/`. The old local `bin/` directory is not used.
## Dependencies
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
```
+14 -78
View File
@@ -6,89 +6,25 @@ import (
"fyne.io/fyne/v2"
)
// 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 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.
//
// # Cross-platform icon strategy
// The blank import enables the compiler directive below; no runtime package
// initialization from embed is required.
//
// 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-large.png to 16px is muddy, and upscaling
// the 16px icon to 32px is blurry. The fix is to feed each surface a
// size-appropriate source — which differs per platform because each platform
// exposes different icon channels.
//
// 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).
// - Desktop toasts: AppMetadata.Icon (set after NewWindow in run.go) feeds
// SendNotification without calling SetIcon, which would override GLFW_ICON.
//
// Linux / other non-Windows (no PE icon resource exists):
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
// _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-big.png
var iconBytes []byte
//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 {
return fyne.NewStaticResource("gosentry-icon-large.png", iconLargeBytes)
// 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)
}
// 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), iconLargeBytes...)
return append([]byte(nil), iconBytes...)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 B

-120
View File
@@ -1,120 +0,0 @@
<!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: 66 KiB

After

Width:  |  Height:  |  Size: 68 KiB

-41
View File
@@ -1,41 +0,0 @@
# 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
@@ -1,120 +0,0 @@
#!/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.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-97
View File
@@ -1,97 +0,0 @@
#!/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}")
+39 -275
View File
@@ -1,309 +1,73 @@
# GoSentry Architecture
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
```
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.
## Component Diagram
```mermaid
flowchart LR
user["Desktop user"]
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"]
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"]
logs["logs_dir - per-run command output logs"]
shell["Platform shell - cmd.exe /C or sh -c"]
user -->|"edits jobs, settings, runs commands"| ui
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
svc -->|"OpenStore, PrepareSaveJobs, PrepareSaveConfig, LoadJobsFile"| store
user -->|"edits jobs, settings, runs commands"| gui
gui -->|"OpenStore, SaveConfig, SaveJobs"| store
store -->|"read/write"| config
store -->|"read/write"| jobs
svc -->|"Start(RunDue)"| sched
sched -->|"RunDue(now)"| svc
svc -->|"RunJob"| runner
gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler
scheduler -->|"SaveJobs after state changes"| store
scheduler -->|"RunJob(trigger)"| runner
runner -->|"execute command"| shell
runner -->|"write stdout/stderr log"| logs
runner -->|"RunRecord"| svc
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
ui -->|"display jobs, history, status"| user
runner -->|"RunRecord with status, duration, log path"| scheduler
scheduler -->|"onChange RunRecord"| gui
gui -->|"display History, command output, job state"| user
svc -->|"Set / Status via Manager"| autostart
gui -->|"SetAutostart, AutostartStatus"| autostart
autostart -->|"use executable path from resolved Paths"| config
```
## Platform layer
GoSentry ships one binary per target OS. Platform-specific code is not a
workaround for missing cross-platform support — it **is** the cross-platform
strategy: shared interfaces and call sites, with OS-specific implementations
selected at **compile time** (`*_windows.go`, `//go:build linux`, and similar).
Runtime `runtime.GOOS` checks appear only for small UI details (see below), not
for autostart, file-manager integration, or command invocation.
Callers (`app.Service`, `ui`, `runner`) depend on the shared API; they do not
branch on the operating system.
| Package / file | Windows | Linux | Other (`!windows && !linux`) |
| --- | --- | --- | --- |
| `platform/autostart` | Startup-folder `.lnk` shortcut | XDG `~/.config/autostart/gosentry.desktop` | Stub — `Set` returns an error when enabled |
| `platform/desktop` | no-op | Installs `.desktop` + icon under XDG data home | no-op |
| `platform/filemanager` | `explorer` | `xdg-open` | Unsupported — `Open` returns an error |
| `platform/winproc` | `CREATE_NO_WINDOW` / `HideWindow` on child processes | no-op | no-op |
| `runner/invocation_*` | `cmd.exe /S /C` with Windows-safe quoting | `sh -c` | `sh -c` (same as Linux) |
**Why separate implementations are required**
- **Autostart** — each OS defines its own login startup mechanism (shortcut,
XDG Autostart, LaunchAgents on macOS). There is no portable API in Go, Fyne, or
the standard library; a third-party helper would still wrap the same per-OS
code behind an interface.
- **Opening a folder** — the desktop shell exposes no shared “reveal in file
manager” call; each platform invokes its registered handler (`explorer`,
`xdg-open`, `open` on macOS).
- **Command shell** — users expect OS-native semantics (`cmd.exe` batch files,
`%VAR%`, and path rules on Windows; POSIX `sh` on Linux). A single shell for
all platforms would break commands on one side or the other.
- **Hidden console window** — launching a child process from a GUI app can flash
a console on Windows only; Linux and macOS do not need equivalent flags.
**Deliberate platform choices (not OS API limits)**
- **Window and tray icons** — Fyne accepts icons on every platform, but Windows
renders the notification area and titlebar from multi-size `.ico` resources
(embedded via `packaging/windows/gosentry.rc`), while Linux StatusNotifier
trays scale better from a larger PNG. `ui/run.go` and `ui/tray.go` branch on
`runtime.GOOS` for asset selection only.
- **Sample job commands** in `storage/store.go` — demo `echo` lines differ only
because shell quoting rules differ; real jobs are user-authored per platform.
**Adding new platform code**
- Put OS integration in `src/platform/<name>/` with a small shared API, or use
`*_GOOS.go` files in the owning package when the surface is a single function
(as in `runner/invocation_*`).
- Do not scatter `runtime.GOOS` through `app.Service` or UI business logic.
- Unsupported platforms get an explicit stub (return an error or no-op) rather
than silently doing nothing — see `autostart_other.go` and
`filemanager_other.go`.
macOS autostart and file-manager handlers are not implemented yet; see
[ROADMAP.md](ROADMAP.md) for blocked or deferred cross-platform work (for
example window-maximized detection, which would need per-OS native calls).
## Main Flows
1. Startup:
`cmd/gosentry` calls `ui.Run`, which owns the process lifecycle: it calls
`app.Open()` to open the store, load `gosentry.json` and `jobs.json`, and
build the `app.Service`, then hands that Service to `newMainView`
(`mainwindow.go`), which subscribes the UI to service events and calls
`Service.Start` to begin the scheduler loop before assembling the tabs.
`Run` shows the window and, on quit, calls `Service.Stop`.
On every launch the service seeds per-job run-time statistics
from existing log files so the details panel reflects accumulated history
immediately (see §Statistics below).
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.
2. Editing settings or jobs:
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, and next-run times around it, applies the statistics
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
`JobChanged`. A path with no file behind it receives the current jobs instead.
Adoption drops all runtime state, so it is refused while a job is running.
Reading the new file and seeding its statistics both happen before `mu` is
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
running-job check is re-evaluated under the lock before anything is replaced.
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.
3. Scheduled run:
`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.
`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.
4. Manual run:
`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.
`Run now` calls the same scheduler path as scheduled execution, but the
resulting history record uses the `Manual` trigger.
5. Command execution:
`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).
`Runner` executes the command through the platform shell, captures stdout and
stderr, writes one timestamped `.log` file, and returns a `RunRecord`.
6. History update:
When a run goroutine completes, `Service` updates the job's runtime
(including the statistics aggregate) under `mu`, then — after releasing it —
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
History rows exist only for the current process session; restarting the app
clears the table (aggregate stats in the details panel are still seeded from
log files).
The scheduler receives the `RunRecord`, updates the matching job, saves YAML,
runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
7. Autostart:
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
interface has two implementations: Windows writes a `.lnk` shortcut to the
user Startup folder; Linux writes an XDG Autostart `.desktop` file. When
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
off the entry launches the executable without that flag so the main window
opens after sign-in.
8. Error surfacing:
Background errors (failed JSON saves, cleanup errors) are emitted as
`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 are
built on `context.Background()` instead — neither the timeout nor app shutdown
applies to them — and so measure launch latency only.
### 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, computed as `DurationSumMS / TimedRunCount` on every update rather than folded incrementally, so it never disagrees with the exact sum/count average `runner.aggregateLogStats` computes when seeding from logs |
| `MaxDurationMS` | longest recorded run |
| `TimedRunCount` | runs that carried a duration, and so contributed to the aggregates above; a legacy log without a `duration` header counts toward `RunCount` but not this |
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
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 six files along these seams:
| File | Contents |
|------|----------|
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
The widgets hold no job state of their own: they read `jobsViewState`, which is
the only thing that reads the Service. The **selection is a job ID, not a row
index.** Every path that changes the job list replaces the state's snapshot —
create, delete, and edit from this view's own handlers, adopting a different
jobs file from the Service, which the view only learns about through the refresh
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
job now sits there, so the details pane would describe one job while the list
highlighted another. Rows are derived from the ID at render time
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
list's highlight at the selected job.
### `settings_view.go` file structure
`src/ui/settings_view.go` is split across three files the same way, once its
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) |
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.
-837
View File
@@ -2,843 +2,6 @@
All notable GoSentry changes are recorded in this file.
## 1.0.3 - 2026-08-07
**The findings of a whole-project review: durable JSON and log writes, bounded
History and overlap queues, and a Jobs selection that follows the job.**
**Application:**
- Fixed a Windows quoting bug where a job whose **Command** field held a whole
command line (a `.bat`/`.cmd` wrapper followed by an argument that itself
ended in `.exe`) had its entire command line mistaken for the program path,
so the run failed with an unmappable shell error. The program path is now
found by the earliest file-extension match at a word boundary, not the first
extension in list order.
- `gosentry.json` and `jobs.json` (and run log files) are now written
atomically — to a temp file, then renamed into place — so a crash or power
loss mid-write can no longer leave a truncated or empty file. `Service.Stop()`
is now called when the app quits, which also makes the run context
cancellation reach in-flight runs on shutdown.
- Fixed the "queue" overlap policy's backlog (`PendingRuns`): it no longer
survives a global pause or a job being disabled, so resuming or re-enabling a
job can no longer replay a deferred run left over from before the pause/
disable. It is also capped at 10 queued occurrences, so a job whose runs take
longer than its own interval no longer accumulates an unbounded backlog that
then runs back-to-back indefinitely. The job details pane now shows the
queued-run count (", N queued") whenever it is non-zero.
- **Start-only jobs are no longer tied to the application's lifetime.** A job
with *Start only* checked is launched on an uncancelable context, so quitting
GoSentry (or a run context being cancelled) can no longer try to kill a
process it deliberately stopped waiting for. This also removes a goroutine
that leaked on every start-only run and lived until the app exited.
- The History tab no longer grows without bound: it keeps the newest 1000
records and drops the oldest, the way a job's own activity list is capped.
Column widths are also folded in one record at a time instead of being
re-measured across every row on every event, so recording a run no longer
gets slower the longer the app has been running. Measured on 5000 accumulated
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
the width rescan alone accounted for 1.5 ms of every redraw.
- Two runs of the same job that start within the same second no longer share a
log file name. The later one gets a `-2`, `-3`, … suffix instead of silently
overwriting the earlier one's log — reachable with a fast manual re-run or a
sub-second queue drain.
- A hand-edited `jobs.json` in which two entries carry the same `id` no longer
leaves them sharing one runtime, one parsed schedule, and one statistics
bucket; the duplicate is reassigned a free ID on load, as an absent ID always
was.
- The **average run duration** shown in Statistics is now the exact sum divided
by the timed-run count rather than an incrementally folded integer mean. The
old form truncated on every run and the error compounded over a job's life,
so the live figure drifted away from the one rebuilt from log files after a
restart.
- On Linux, a failure to install the `.desktop` file or icon is now reported in
History instead of being discarded, so the visible symptom — a generic dock
icon — has an explanation.
**Jobs:**
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
different jobs file in Settings replaces the whole job list; the details pane
then described whichever job happened to land on the previously selected row —
or went blank if the new list was shorter — while the highlight in the list
stayed where it was. The selection now follows the job itself, and the
highlight and the details pane always describe the same one.
- Switching the **Folder** filter now keeps the current selection when the new
filter still shows that job, instead of always jumping to the folder's first
job.
**Settings:**
- **Max log files and max log age days now accept 0, meaning "keep
everything."** Log cleanup already supported disabling either policy; the
Settings form and the Service validator rejected the value that would have
turned it on. A config that already set either to 0 is no longer silently
rewritten back to the 100/30 defaults on load.
- Opening the tab and saving no longer block the window while the autostart
status is read — on Windows that check shells out to PowerShell, and it now
runs off the UI thread.
- Two spellings of the same absolute **Jobs file** path (mixed separators, a
trailing separator) no longer read as a change of file, so saving no longer
triggers a spurious reload of the file already in use.
**Documentation:**
- Documentation audited against the code. `ARCHITECTURE.md` — the `jobs_view.go`
split is six files, not five (the state extraction was never counted), the
statistics table lists `TimedRunCount`, the store edge of the diagram names
the methods that exist, and startup says where `Service.Start` is actually
called. `TESTS.md` — three tests that had no entry are described
(`TestLoadOrCreateConfigPreservesZeroRetentionLimits`,
`TestWriteJSONReplacesFileAtomically`,
`TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension`), the
deliberately-uncovered list covers everything the profile reports at 0%, and
the coverage figure records how to read the total rather than the per-package
lines. `ROADMAP.md` — the over-the-guideline table was re-measured.
- README's scheduler wording caught up with the 0.11.2 rename of "Pause all" to
**Disable auto**, and its notification description matches what the app sends.
- `STANDARDS.md` records the rules the review settled: no file I/O under
`Service.mu`, the History and pending-run caps, the zero-retention meaning,
that a start-only process outlives GoSentry, the single-instance fallback's
consequence, and the unauthenticated instance-channel port.
- `docs/REVIEW.md` (the whole-project review agenda) and the working plan it
produced are retired now that every item is either landed here or recorded in
`ROADMAP.md`, the way the test review plan was in 1.0.1. `STANDARDS.md` is the
surviving reference.
- The screenshots moved to `docs/screenshots/`.
**Internal:**
- The failure-notification timing diagnostic added in 1.0.2 is now written to
`logs/notify-timing.tsv`. The `.tsv` extension keeps it out of `CleanupLogs`,
which manages only `.log` files, so it is neither deleted by age nor counted
against **Max log files**, and the append now runs off the UI thread.
- `jobs.json` is no longer rewritten twice per run. Starting and finishing a run
touch only `JobRuntime`, which is never persisted, so both saves re-serialised
identical bytes; `SetGlobalPause` did the same alongside its real `SaveConfig`.
Removing them also removes the run-start rollback path and the save failure it
reported, so `RunDue` no longer has a start error to surface at all.
- File I/O no longer happens while `Service.mu` is held — that is the lock the
UI thread takes on every job and runtime read, so a JSON write, the
post-run log cleanup, or the startup log scan used to make a UI refresh wait
on the disk. Saves are now prepared under the lock and written after it is
released, in preparation order, so `jobs.json` still ends up matching the
in-memory list. Seeding statistics from logs also opens each log file once
instead of twice.
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
`jobs_view_list.go`, and `jobs_view_toolbar.go`, joining the existing
`jobs_view_details.go` and `jobs_view_helpers.go`. What used to be one
330-line constructor whose dozen closures shared seven mutable locals is now
widgets reading one named state object — which is what made the selection fix
above a change in one place instead of five.
- `Service.Store()` is replaced by typed `Service.Config()` and `Service.Paths()`
accessors that copy under the lock, so the UI no longer reaches into a shared
`*storage.Store`. The Jobs pause control is now driven by `refreshView`
reading `svc.Config().Paused` on every event, making it a real consumer of
`SchedulerStateChanged`, and the main window's event listener is a type switch.
- Dead code removed: `collectActivity`, the `yaml` tags on `RunRecord`, the
`logArguments`/`LogArguments` alias, the redundant package-level
`SetAutostart`/`AutostartStatus` functions, and the Settings Save handler's
second copy of the Service's validation rules. The
`systemTrayRegistered`/`mainWindowHidden` globals are one `trayState` value
that `Run` owns and threads through.
- `scripts/test.bat` no longer prints mojibake for its checkmarks under a
non-UTF-8 code page.
## 1.0.2 - 2026-08-05
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
the app icon (experimental).**
**Application:**
- **Keep running in the system tray** now controls behaviour: with the tray on
(default), closing the window hides it and autostart uses `--start-in-tray`;
with the tray off, closing quits the app and autostart opens the main window.
- Saving a tray change updates close behaviour and the autostart entry
immediately. The notification-area icon follows the saved value after a
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
remove the icon mid-session).
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
the window when the tray setting is off — saved config wins over the CLI flag.
- On Windows, failure toasts can show the app icon: after `NewWindow`,
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
`SetIcon`, which would override the PE multi-size window/taskbar icon.
**Jobs:**
- New disabled example *Failure notification test* (folder Examples). Run it
manually to trigger a failed run and verify Settings → Notifications without
waiting on the scheduler.
**Documentation:**
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
for adding platform code.
**Internal:**
- App-side failure-notification timing is appended to `logs/notify-timing.log`
for diagnosing toast delay (OS latency excluded).
`scripts/measure-windows-toast.ps1` measures the PowerShell baseline on
Windows.
## 1.0.1 - 2026-08-04
**The branded theme is the default, Fyne's built-in theme is System, and the
test suite is leaner.**
**Settings:**
- **The branded GoSentry theme is now the default.** Fresh installs, the
**Defaults** button, and configs that omit `theme` all open in the teal/amber
look; users who prefer Fyne's built-in theme can still pick **System** in
Settings.
- The Fyne built-in theme option is labelled **System** (stored as `"system"`);
configs that still say `"default"` are read as System and rewritten on save.
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
the private Gitea mirror.
**Jobs:**
- The **Disable auto** row gained a top inset matching the gap below it, so it
no longer sits flush against the tab bar.
**Documentation:**
- The **README Schedules** section now documents `@every` in full: supported Go
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
`time.ParseDuration`, the fact that days/months/years belong in cron rather
than `@every`, the one-second scheduler tick floor, cron examples for monthly
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
**Tests:**
- Three tests with byte-identical coverage to an existing test and no unique
assertion are gone: `TestCleanupLogsKeepsFilesWithinAgeLimit`,
`TestRunDueEmptyOverlapInheritsGlobal` (its one unique setup guard moved into
`TestRunDueQueueRerunsAfterFinish`), and `TestSameWindowsPathHandlesSpaces`
(its spaces case folded into `TestSameWindowsPathIgnoresCaseAndQuotes`'s
fixture). So are two that carried no assertion at all:
`TestEmitWithNoObserversIsNoop` and `TestStoreReturnsWiredStore`.
- The four `TestFilteredJobIndexes*` tests are one table-driven
`TestFilteredJobIndexes`, matching `TestFilterValue` above it.
- `TestMainViewBuilds` is replaced by `TestMainViewRecordStartupAddsHistoryRow`,
which exercises the `recordStartup` closure for both wordings `run.go` selects
between and asserts the rows reach the History table through its cell callbacks,
including the `!windowShown` branch.
- `storage.defaultJobs` — the one accidental 0%-coverage gap the review
found — is now exercised by
`TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun`, which also corrects
`docs/TESTS.md`: `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` never
touched jobs, so the "and a sample job" half of its old description was
wrong.
- `src/runner/seed_test.go`'s hand-rolled `itoa` — 18 lines of digit-by-digit
conversion in a file that already imports `strconv` — is replaced with
`strconv.FormatInt`.
- **`docs/TESTS.md`** records the `-coverpkg` command and the 84.4% baseline
(per-package figures understate the suite), design principle 9 (redundancy is
judged by comparing coverage profiles, and identical coverage alone is not
grounds for deletion), a table of the look-alike tests that are kept with the
reason each survives, and the list of functions deliberately at 0%.
- **`docs/STANDARDS.md`**'s "Intentional behavior" section points at both lists,
so the mechanism `docs/REVIEW.md` describes still reaches them. The spent test
review plan is retired.
## 1.0.0 - 2026-07-27
**The window opens at the size it asks for, and the Jobs divider can be
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.
-352
View File
@@ -1,352 +0,0 @@
# 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 (`docs/screenshots/screenshot_jobs.PNG`,
`docs/screenshots/screenshot_settings.PNG`,
`docs/screenshots/screenshot_history.PNG`) if the GUI changed its appearance. This is
easy to forget because nothing fails without it: `README.md` is packaged
inside every release archive and is what the forge shows on the project page,
so a stale shot advertises an application that no longer exists. Take them
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
@@ -1,79 +0,0 @@
# 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.
+304
View File
@@ -0,0 +1,304 @@
# GoSentry Refactoring Plan
Status: proposed — not yet started.
Goal: make the codebase **solid**, **comprehensive**, and **human-readable / maintainable**
without changing observable behavior.
This document is the single source of truth for the refactor. It records the
target architecture, the rationale, and a sequence of small, independently
reviewable tasks. Each task lists the recommended agent model and effort level.
---
## 1. Why refactor
The application works and is well-commented, but its structure does not scale:
| # | Problem | Impact |
|---|---------|--------|
| 1 | `src/gui/app.go` is a 1,057-line monolith | Nothing can be found, reused, or tested in isolation |
| 2 | `src/core` is one flat package mixing 7 concerns | No boundaries; everything can call everything |
| 3 | **Shared mutable `*[]Job`** between GUI and `Scheduler` | GUI mutates the slice with no lock; scheduler locks the same slice → data race |
| 4 | `onChange` mutates Fyne widgets **from the scheduler goroutine** | Latent crash/corruption — Fyne requires UI updates on the main thread |
| 5 | `Job` mixes durable config and runtime state (`yaml:"-"` fields) | The "noise" the model fights to exclude lives in the same struct |
| 6 | Errors swallowed everywhere (`_ = store.SaveJobs(...)`) | Save failures are invisible to the user |
| 7 | No service/controller layer; GUI reaches into `store.Paths`, drives scheduler directly | Business logic is tangled into widget callbacks |
| 8 | Schedule strings re-parsed every tick; no `Schedule` value type | Validation scattered; no single source of truth |
| 9 | Tests only cover `core`; GUI and orchestration untestable | Documented gap in `docs/TESTS.md` |
> Note on layout: the project intentionally **keeps the `src/` directory**. The
> `src/` → `internal/` move was considered and rejected — it is cosmetic for a
> non-imported desktop app and not worth the import-path churn. All packages
> below live under `src/`.
---
## 2. Target architecture
The central change is to **insert an application-service layer** that owns all
state and exposes intent-based methods. This turns the UI into a thin view and
the core packages into stateless engines, dissolving problems 3, 4, 6, and 7.
```
┌──────────────┐ intents ┌─────────────────┐ calls ┌──────────────┐
│ ui (Fyne) │ ───────────▶ │ app.Service │ ─────────▶ │ core engines │
│ thin views │ ◀─────────── │ (sole owner of │ │ scheduler / │
│ fyne.Do only │ events │ state + mutex) │ ◀───────── │ runner / │
└──────────────┘ └─────────────────┘ records │ storage │
└──────────────┘
```
- **One writer.** `app.Service` holds the job list + runtime state behind a
mutex. The UI never mutates state directly — it calls `CreateJob`, `RunNow`,
`SetGlobalPause`, etc.
- **Events flow back** through an observer interface. The UI's listener is the
*only* place that touches widgets, and it marshals onto the main thread with
`fyne.Do`.
- **Core engines are stateless / injected** — scheduler and runner operate on
data passed in, not a shared slice.
### 2.1 Package layout (all under `src/`)
```
cmd/gosentry/
main.go # flag parse → ui.Run
src/
domain/ # pure types, zero external deps
job.go # Job (durable config only — no yaml:"-")
runtime.go # JobRuntime (LastRun/NextRun/State/Output/Logs)
record.go # RunRecord
config.go # Config + StartInTrayArgument
schedule.go # Schedule value object: Parse / Validate / Next()
storage/ # persistence + path resolution + migration
store.go # Load/SaveConfig, Load/SaveJobs
paths.go # ResolvePaths
yaml.go # writeYAML helper
migration.go # pysentry → gosentry legacy handling
scheduler/
scheduler.go # timing loop; drives Service via callbacks
clock.go # Clock interface (real + fake for tests)
runner/
runner.go # RunJob orchestration
invocation.go # build exec.Cmd (shared)
invocation_windows.go # cmd.exe quoting
invocation_other.go # sh -c
exitcodes.go # parse / accept success codes
logfile.go # writeRunLog + sanitizeFileName
cleanup.go # CleanupLogs
platform/
winproc/ # hidden-window helper shared by runner + autostart
winproc_windows.go # CREATE_NO_WINDOW / HideWindow
winproc_other.go # no-op
autostart/
autostart.go # Manager interface + Status type
windows.go linux.go other.go
desktop/
desktop_linux.go other.go
app/
service.go # owns state; CreateJob/UpdateJob/Delete/RunNow/...
events.go # Event types + Observer registration
format.go # display strings (moved out of GUI)
ui/ # renamed from src/gui; thin Fyne views
run.go # Run(): lifecycle, window, tray wiring
mainwindow.go # tab assembly + event listener (fyne.Do)
jobs_view.go # list + details panel + toolbar
job_dialog.go # new/edit form
history_view.go # history table
settings_view.go # settings form
tray.go # system tray
singleinstance.go # localhost IPC
layout.go # minWidthLayout
```
Import paths follow the existing convention, e.g.
`gitea.mixdep.ru/mix/gosentry/src/domain`,
`gitea.mixdep.ru/mix/gosentry/src/app`.
### 2.2 Dependency direction (must stay acyclic)
```
domain ← (no deps)
storage ← domain
runner ← domain, platform/winproc
scheduler← domain
app ← domain, storage, scheduler, runner
ui ← app, domain (Fyne)
platform/autostart, platform/desktop ← (own deps; winproc for windows)
cmd ← ui
```
### 2.3 Key design decisions
1. **Split durable vs. runtime in the domain.** `domain.Job` becomes pure YAML
config (no `yaml:"-"`). Runtime state moves to `domain.JobRuntime`, held by
the service keyed by job ID. (Resolves #5.)
2. **`Schedule` value object.** `schedule.Parse(string) (Schedule, error)`
validates once and exposes `Next(time.Time)`. (Resolves #8.)
3. **Autostart behind a `Manager` interface**, selected per platform — mockable,
no package-level functions.
4. **Injectable `Clock`** in the scheduler → deterministic tests.
5. **Errors surface to the UI.** Service methods return errors; status bar shows
them. No more `_ =` on saves. (Resolves #6.)
6. **Thread-safety contract:** core engines never import Fyne; the UI listener is
the sole widget mutator and always wraps updates in `fyne.Do`. (Resolves #4.)
---
## 3. Task sequence
Tasks are ordered so the tree **compiles and all tests pass after every task**.
Each task is a small, reviewable unit.
**Model guidance**
- `haiku` — mechanical moves, renames, no judgment required.
- `sonnet` — localized logic changes with clear scope.
- `opus` — architecture-shaping work (new layers, concurrency, public APIs).
**Effort guidance** — reasoning depth, not size: `low` / `medium` / `high`.
### Phase 0 — Safety net
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T0.1 | Add `scripts/test.sh` + `.bat` running `go vet ./...` and `go test -race ./...`. Document in `docs/TESTS.md`. | haiku | low |
| T0.2 | Add characterization tests that pin current behavior at seams to be moved: store load→save round-trip, scheduler `nextRunTime`, end-to-end `RunJob` log output. (Some exist; fill gaps.) | sonnet | medium |
### Phase 1 — Split the flat `core` package (no logic change)
Mechanical moves + import fixes only. Behavior identical.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T1.1 | Create `src/domain`; move `Job`, `RunRecord`, `Config`, `JobsFile`, `StartInTrayArgument` from `model.go`. Keep `yaml:"-"` fields for now (split happens in Phase 2). Update all references. | sonnet | medium |
| T1.2 | Create `src/platform/winproc`; move `configureHiddenWindow` + hidden-window flags out of `runner_windows.go` / `runner_other.go`. This breaks the future autostart→runner coupling early. | sonnet | medium |
| T1.3 | Create `src/runner`; move `runner.go`, `runner_windows.go`, `runner_other.go`, `runner_test.go`. Point at `winproc`. Split helpers into `invocation*.go`, `exitcodes.go`, `logfile.go`, `cleanup.go` as the file moves. | sonnet | medium |
| T1.4 | Create `src/scheduler`; move `scheduler.go`, `scheduler_test.go`. Still takes `*[]domain.Job` for now. | sonnet | medium |
| T1.5 | Create `src/storage`; move `store.go`, `paths.go`, `store_test.go`. | sonnet | medium |
| T1.6 | Create `src/platform/autostart`; move `autostart_*.go` + tests. Point at `winproc`. | sonnet | medium |
| T1.7 | Create `src/platform/desktop`; move `desktop_linux.go`, `desktop_other.go`. | haiku | low |
| T1.8 | Delete the now-empty `src/core`; run full build + tests on both platforms (or with build tags) to confirm parity. | haiku | low |
### Phase 2 — Domain cleanup
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T2.1 | Add `src/domain/schedule.go`: `Schedule` value object with `Parse`, `Validate`, `Next(time.Time)`. Unit-test it. Keep `nextRunTime` as a thin wrapper initially. | opus | high |
| T2.2 | Migrate `scheduler` to use `Schedule` (parse on load/edit, not per tick). Remove duplicated parsing. | sonnet | medium |
| T2.3 | Split `domain.Job` (durable) from `domain.JobRuntime` (transient). Remove all `yaml:"-"` fields and `nextDue` from `Job`. Add `runtime.go`. | opus | high |
| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. **(Completed as part of T2.3 — removing the runtime fields from `Job` forced all three deliverables. Runtime-map ownership is deferred to T3.1.)** | sonnet | medium |
> After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime`
> split is wired through temporary glue. Phase 3 removes the sharing.
### Phase 3 — Application service layer
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T3.1 | Create `src/app/service.go`: `Service` owning `[]domain.Job` + `map[int]*domain.JobRuntime` behind a `sync.Mutex`. Constructor wires `storage`. | opus | high |
| T3.2 | Add `src/app/events.go`: `Event` types (job changed, run recorded, scheduler state) + `Observer` registration. Single-threaded dispatch contract documented. | opus | high |
| T3.3 | Move state-mutating operations into the service: `CreateJob`, `UpdateJob`, `DeleteJob`, `SetEnabled`, `RunNow`, `SetGlobalPause`, `UpdateSettings`. Each returns `error`. | opus | high |
| T3.4 | Convert `scheduler` to operate through the service (no `*[]Job`). Scheduler asks the service for due jobs and reports records back; service is the sole writer. Inject `Clock`. | opus | high |
| T3.5 | Move display/format helpers (`displayFolder`, `displayArguments`, `displayRunMode`, `statusText`, …) from GUI into `src/app/format.go`. | haiku | low |
| T3.6 | Add `src/app` unit tests (no Fyne): create/edit/delete, enable/pause, global pause, run-now path with a fake runner + fake clock. Big coverage win. | opus | high |
### Phase 4 — Carve up the GUI
Rename `src/gui``src/ui` and break `app.go` into focused files. The UI now
talks only to `app.Service` and reacts to events via `fyne.Do`.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T4.1 | Rename package `gui``ui`; split lifecycle into `run.go` + `mainwindow.go`. Wire the event listener and route every widget update through `fyne.Do`. (Resolves #4.) | opus | high |
| T4.2 | Extract `jobs_view.go` (list + details + toolbar), driven by service calls + events. | sonnet | medium |
| T4.3 | Extract `job_dialog.go`; validate schedule via `domain.Schedule.Validate`. | sonnet | medium |
| T4.4 | Extract `history_view.go`. | sonnet | medium |
| T4.5 | Extract `settings_view.go`; surface save/autostart/cleanup errors to the status label. (Resolves #6 in UI.) | sonnet | medium |
| T4.6 | Extract `tray.go`, `singleinstance.go`, `layout.go`. | haiku | low |
| T4.7 | Confirm `app.go` is gone and `ui` imports only `app` + `domain` + Fyne. Manual smoke test on each platform. | sonnet | medium |
### Phase 5 — Hardening & docs
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T5.1 | Replace remaining `_ = ...Save...` with propagated/surfaced errors across service + storage. | sonnet | medium |
| T5.2 | Introduce `autostart.Manager` interface + per-platform impls; inject into the service instead of calling package funcs. | sonnet | medium |
| T5.3 | Fill documented test gaps: folder filtering, log cleanup (count + age), settings persistence/migration, concurrent run prevention. | sonnet | high |
| T5.4 | Run `go test -race ./...` clean. Confirm no data race remains. | haiku | low |
| T5.5 | Update `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and the README "Project Layout" section to the new structure. | sonnet | medium |
---
## 3.1 Task completion checklist
Track progress here. Mark tasks complete as they land and pass review.
### Phase 0 — Safety net
- [x] T0.1 — Add test script + `go vet` + `go test -race`
- [x] T0.2 — Add characterization tests
### Phase 1 — Split flat `core` package
- [x] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
- [x] T1.3 — Create `src/runner`; move runner logic
- [x] T1.4 — Create `src/scheduler`; move scheduler
- [x] T1.5 — Create `src/storage`; move store/paths
- [x] T1.6 — Create `src/platform/autostart`; move autostart logic
- [x] T1.7 — Create `src/platform/desktop`; move desktop integration
- [x] T1.8 — Delete empty `src/core`; build + test both platforms
### Phase 2 — Domain cleanup
- [x] T2.1 — Add `src/domain/schedule.go`; Schedule value object
- [x] T2.2 — Migrate `scheduler` to use Schedule
- [x] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
- [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_
### Phase 3 — Application service layer
- [x] T3.1 — Create `src/app/service.go`; owns state behind mutex
- [x] T3.2 — Add `src/app/events.go`; Event types + Observer
- [x] T3.3 — Add state-mutating operations to service
- [x] T3.4 — Convert `scheduler` to use service; inject Clock
- [x] T3.5 — Move display helpers to `src/app/format.go`
- [x] T3.6 — Add `src/app` unit tests (no Fyne)
### Phase 4 — Carve up the GUI
- [x] T4.1 — Rename `gui``ui`; split app.go into run.go + mainwindow.go _(required Fyne v2.5.3→v2.6.3 upgrade for `fyne.Do`)_
- [x] T4.2 — Extract `jobs_view.go`
- [x] T4.3 — Extract `job_dialog.go`
- [x] T4.4 — Extract `history_view.go`
- [x] T4.5 — Extract `settings_view.go`
- [x] T4.6 — Extract `tray.go`, `singleinstance.go`, `layout.go`
- [ ] T4.7 — Confirm app.go is gone; smoke test both platforms
### Phase 5 — Hardening & docs
- [ ] T5.1 — Surface errors from service + storage
- [ ] T5.2 — Introduce `autostart.Manager` interface
- [ ] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency)
- [ ] T5.4 — Run `go test -race ./...` clean on both platforms
- [ ] T5.5 — Update docs (ARCHITECTURE.md, TESTS.md, README)
---
## 4. Definition of done
- `go vet ./...` clean; `go test -race ./...` green on Windows and Linux.
- No package outside `ui` imports Fyne; no engine mutates UI state.
- `domain.Job` has no `yaml:"-"` fields.
- `app.Service` is the only writer of job/runtime state.
- `src/ui` contains no file over ~250 lines; no single file over ~400.
- `docs/ARCHITECTURE.md` matches the shipped structure.
## 5. Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| Cross-platform code moves break the non-host OS build | Build with both `GOOS=windows` and `GOOS=linux` after each platform-touching task (T1.2, T1.3, T1.6, T1.7). |
| Concurrency change (Phase 3/4) introduces subtle deadlocks | Keep the service mutex non-reentrant; never call back into the UI while holding it; cover with `-race` tests in T3.6. |
| Behavior drift during moves | Characterization tests (T0.2) pin behavior before structural change. |
| Large diff hard to review | Each task is a separate commit/PR; phases land independently. |
+53 -212
View File
@@ -1,230 +1,71 @@
# 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.
## Open Items
## Post-Field-Test Cleanup
### Faster Windows failure notifications
After real-world use confirms the main workflows, clean up temporary
stabilization code and development scaffolding.
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
writes a short script to `%TEMP%` and runs it through a **new PowerShell
process** (`app/app_windows.go`), which typically adds **13 seconds** of cold
start before the toast appears. GoSentry's own path from run completion through
`SendNotification` is much smaller and is logged separately.
Cleanup checklist:
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
machine):** average **773 ms** per toast (695874 ms), dominated by PowerShell
cold start. Re-run the script when comparing after a native toast implementation.
- 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.
**App-side timing:** each failure notification appends one line to
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
toast latency is not included. The `.tsv` extension keeps it out of
`runner.CleanupLogs`, which only manages `.log` files — this file is
diagnostic instrumentation for this item, not job output, and should be
removed (or unified with the run-log retention policy under its own knob) once
the native-toast direction below lands and the timing data is no longer
needed.
## Tray Interaction
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
needs the same treatment.
Improve tray icon interaction: click the tray icon to show and focus the main
window.
### Retire the config compatibility shims
- Unblocked by Fyne 2.7.0, which added `desktop.App.SetSystemTrayWindow(window)`.
On Windows, macOS, and most Linux it shows the associated window on left-click;
any tray menu then moves to right-click. There is still no raw click /
double-click callback, so the behavior is single left-click (the conventional
tray gesture), not the double-click originally sketched here.
- The project is currently on Fyne 2.6.3, so this depends on a Fyne 2.6.3 -> 2.7.x
upgrade first (minor bump; re-verify the CGO build under MSYS2 UCRT64 and check
for 2.7 breaking changes). Track the upgrade as its own task.
- After upgrading, the change in `src/ui/run.go` (configureSystemTray) is small:
call `desk.SetSystemTrayWindow(w)` alongside `SetSystemTrayMenu(menu)`. Keep the
existing "Show" menu item, which the Fyne docs recommend for less-compliant
Linux systems.
Two read-only shims in `storage.loadOrCreateConfig` rewrite an old file into
the current shape on the next save, so each becomes dead the moment a user's
config has been saved once by a build that has it:
## Delivery And Packaging
- `Config.JobsDir` (pre-0.15, superseded by `Config.JobsFile`).
- `Theme == "default"` (pre-1.0.1, superseded by `ThemeSystem`).
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.
Neither has an expiry. Remove both — the field, the migration branch, and
`TestLoadOrCreateConfigMigratesJobsDir` /
`TestLoadOrCreateConfigMigratesLegacyThemeDefault` — once a release has shipped
long enough that a config file still carrying either old shape is not a
realistic upgrade path GoSentry needs to support.
Planned delivery variants:
### Dynamic tray icon toggle
- 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.
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
There is no supported way to register or remove the notification-area icon
after the process is running.
Packaging design note:
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
autostart entry update immediately when the user saves Settings; the tray icon
follows the saved value on the next launch. Settings shows a restart hint when
the tray checkbox changes.
- 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.
Revisit when Fyne adds a documented API for mid-session tray registration, or
when a stable cross-platform approach exists without reaching into driver
internals. Until then, removing the restart hint and applying the icon on save
is blocked.
Initial priority:
### Update check from GitHub releases
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.
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.
Design notes / open questions:
- *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.
### Import/export jobs as a cron table
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.
Export writes one line per job — schedule fields, then command and arguments —
and import parses the same format back into `domain.Job` values.
Design notes / open questions:
- *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.
### Split the files that are over the size guideline
[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. `jobs_view.go` was split again in 1.0.3 — into view, state, list, and
toolbar — because the selection defect it carried was a symptom of the size
(one 330-line constructor over seven shared locals). Six non-test files are
over the guideline:
| File | Lines |
|------|-------|
| `src/app/operations.go` | 529 |
| `src/storage/store.go` | 382 |
| `src/ui/history_view.go` | 355 |
| `src/ui/settings_view.go` | 326 |
| `src/app/run.go` | 275 |
| `src/app/service.go` | 252 |
The remaining six are deliberately deferred rather than done piecemeal: a
split touches every reader of the file, and doing them in one pass keeps the
seams consistent instead of settling each one its own way. Splitting is
also the kind of change that reads as pure movement while quietly dropping a
function, so it wants one careful pass, not a hurried one per file.
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.
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
load/normalize path are three separate concerns in one file.
- **`run.go`**, **`settings_view.go`**, **`service.go`** — barely over. Worth
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
alone rather than splitting for the sake of the number. The counts above move
a few lines either way with any edit, so re-measure before acting on them
rather than treating the table as current.
The `jobs_view.go` pass is the worked example for the rest: the constructor was
broken up along the state it shared, not along line count, and the split landed
with the selection fix rather than promising it separately.
Scope note: the guideline is about source files. Test files are much larger and
that is fine — a table-driven test file grows with the cases it covers.
### 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.
1. Windows portable `.zip`.
2. Linux portable `.tar.gz` for amd64 and arm64.
3. Debian/Ubuntu `.deb`.
4. Windows installer.
-145
View File
@@ -1,145 +0,0 @@
# GoSentry — Standards
Quality rules and intentional behavior for contributors. Package contracts live
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.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`.
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
log-directory scan, or a pass over every log header inside it makes a UI
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
writes still reach the file in the order their snapshots were taken; log
cleanup and `runner.SeedStats` run from plain snapshots.
- A size that must follow the theme is **measured at build time, not written as
a pixel constant.** `theme.Padding()` and text metrics depend on the running
app's theme, text size, and DPI, so a hand-tuned number is only correct for
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.
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
negative count is rejected), and `storage.loadOrCreateConfig` no longer
backfills 0 to 100 / 30 — a config written before either field existed still
picks up the default because `json.Unmarshal` leaves an absent key holding
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
relies on.
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
launch something and let go of it, so the runner builds that invocation on
`context.Background()`, not on the application's lifecycle context: quitting
GoSentry (or cancelling a run) does not stop a process it started this way, and
`Service.Stop()` reaches only jobs the runner is still waiting on. The
uncancelable context is also what keeps `os/exec` from leaving a watcher
goroutine per run — it only starts one when the context can be done, and
`StartOnly` never calls `Wait` to end it.
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
current process. Log files on disk feed aggregate statistics via `SeedStats`
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
- **History is capped and its columns only widen.** The tab keeps the newest
`maxHistoryRows` records and drops the oldest, the way `maxJobLogs` caps a
job's own activity list — an app left in the tray records thousands of runs a
day, each carrying the run's full captured output. Column widths are folded in
one record at a time instead of rescanned from every row, so a column never
narrows when a record ages out: the rows on screen were laid out against the
wider value. A theme change is the one case that rescans, because every stored
width was measured at the old text size.
- Several tests share a coverage profile with another test on purpose, and a few
functions sit at 0% on purpose. Both lists live in
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
coverage gap as an oversight.
- **`KeepRunningInTray` controls tray and close behavior.** When enabled (the
default), the app registers a system tray icon at launch, closing the window
hides it, and autostart passes `--start-in-tray`. When disabled, no tray icon
is registered at launch, closing the window quits the app, and autostart opens
the main window. Toggling the setting in Settings updates close behavior and
rewrites the autostart entry immediately; the tray icon itself follows the
saved value only after a restart because Fyne has no API to add or remove it
mid-session (see [ROADMAP.md](ROADMAP.md)).
- **`--start-in-tray` defers to config.** A stale autostart shortcut that still
passes the flag does not hide the window when `KeepRunningInTray` is off.
- **`JobRuntime.PendingRuns` (the "queue" overlap policy's backlog) is capped at
`maxPendingRuns` (10) and cleared on pause or disable.** A job whose runs take
longer than its interval stops accumulating backlog once the cap is hit —
further overlaps are dropped like the "skip" policy until the backlog drains
below the cap. `SetGlobalPause(true)` and `SetEnabled(id, false)` both zero
the counter, so resuming or re-enabling a job never replays a deferred run for
an occurrence that fired before the pause/disable. The details pane appends
", N queued" to the statistics line via `DisplayStats` whenever the count is
non-zero.
- **Single-instance arbitration falls back to "start anyway" when the port is
held by something else.** `acquireSingleInstance` (`singleinstance.go`)
binds `127.0.0.1:37653`; if that fails and a dial to the same address does
not answer as GoSentry either, startup continues rather than refusing to
open because of an unrelated local listener. The consequence is deliberate
but worth spelling out: two GoSentry processes can then run two schedulers
against the same `jobs.json` and the same logs directory, each overwriting
the other's saves. Atomic writes (`writeFileAtomic`) prevent a *torn* file
from a concurrent write, but not one process's save clobbering the other's.
- **The single-instance channel is an unauthenticated localhost TCP port.**
Port 37653 accepts one command, `"show"`, from any local process — including
one running as a different user on a shared machine. This is a deliberate
scope choice, not an oversight: the command only raises the existing window,
so the impact of an unwelcome sender is a window popping up, not data
exposure or control. Anything with a larger blast radius on that channel
would need real authentication.
## Out of scope
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).
+68 -584
View File
@@ -1,6 +1,6 @@
# GoSentry Test Suite
All tests are located alongside source code in their respective packages under `src/`. Tests follow Go conventions with `*_test.go` filename patterns.
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
## Running Tests
@@ -22,16 +22,6 @@ 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).
`src/ui` dominates `go test -race ./...`'s wall time — around 229s in the
2026-08-05 whole-project review, against under 8s for every other package
combined. Budget iteration accordingly: a change confined to `domain`,
`storage`, `runner`, `scheduler`, or `app` gets a fast feedback loop; a `ui`
change does not.
### Manual test commands
Run all tests:
@@ -51,591 +41,120 @@ go test -v ./...
Run a specific test by name:
```bash
go test -run TestRunJobWritesLogFile ./src/runner
go test -run TestRunJobWritesLogFile ./src/core
```
Run tests with code coverage:
```bash
go test -cover ./src/runner
go test -coverprofile=coverage.out ./src/runner
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
Per-package coverage understates the suite, because several packages are
exercised from another one's tests — `domain.NewRuntime`, for instance, is
covered by the `app` tests. Measure the engine packages together instead:
```bash
go test -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
```
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
splits the comma-separated `-coverpkg` list on its own and the command fails
with `directory not found`. Use the stop-parsing token, or quote the whole
flag — and note that `--%` swallows the rest of the line, so the profile has to
be read by a second command:
```powershell
go test --% -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
```
The total is the last line of the profile summary. It is **not** any of the
per-package lines `go test` prints: with `-coverpkg` spanning five packages,
each of those reports only what that one package's tests reached across the
whole set, so all five are far below the real figure.
```powershell
go tool cover -func=cover.out | Select-Object -Last 1
```
That total was 84.4% at the 2026-08-04 review and 84.1% at the 2026-08-07
documentation audit — the number to compare against before concluding that
coverage has slipped.
---
## Test Files Overview
### src/domain/schedule_test.go
### store_test.go
**Location:** `src/core/store_test.go`
**Package:** `core`
**Package:** `domain`
Tests schedule parsing and validation.
Tests YAML serialization and storage behavior.
| 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. |
| `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. |
---
### src/domain/config_test.go
### scheduler_test.go
**Location:** `src/core/scheduler_test.go`
**Package:** `core`
**Package:** `domain`
Tests autostart argument helpers and the jobs-list density normalization rule.
Tests schedule parsing and job invocation output formatting.
| Test | Purpose |
|------|---------|
| `TestAutostartArguments` | Verifies `AutostartArguments` returns `--start-in-tray` when the tray is enabled and an empty string when it is off. |
| `TestResolveStartHidden` | Verifies hidden autostart requires both the CLI flag and `KeepRunningInTray`. |
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
| `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. |
---
### src/app/service_test.go
### runner_test.go
**Location:** `src/core/runner_test.go`
**Package:** `core`
**Package:** `app`
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
Tests `Service` construction and the state-accessor contract.
#### Log File Tests
| 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. |
| `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). |
---
### 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. |
| `TestSetEnabledClearsPendingRuns` | Verifies that disabling a job zeroes a `PendingRuns` backlog it was carrying, so re-enabling it later does not replay a stale deferred run. |
#### Global pause / run-now / run-due
| 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. |
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
---
### 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; also covers an empty per-job policy inheriting the global default. |
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
| `TestRunDueQueueCapsPendingRuns` | Regression: `PendingRuns` stops growing at `maxPendingRuns` instead of accumulating without bound for a job that never keeps up with its schedule. |
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused, and pausing clears the backlog rather than leaving it to fire a stale deferred run on resume. |
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
---
### 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. |
| `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. |
| `TestNormalizeJobsReassignsDuplicateIDs` | Verifies that a hand-edited `jobs.json` with two entries sharing one ID gets the later duplicates reassigned instead of colliding on one runtime. |
| `TestResolveConfiguredPathCleansAbsolutePaths` | Verifies (Windows only) that forward-slash and backslash spellings of the same absolute path resolve to the same string. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
| `TestLoadOrCreateConfigPreservesZeroRetentionLimits` | Verifies that `max_log_files` / `max_log_age_days` of 0 read back as 0 ("keep everything") instead of being backfilled to the 100 / 30 defaults — a field the file sets is not the missing-field case. |
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
| `TestWriteJSONReplacesFileAtomically` | Pins the durability fix: `writeJSON` replaces the destination through a temp file and a rename rather than truncating it in place, and leaves no temp file behind. |
---
### 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
#### Command Execution Tests
| 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. |
| `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
#### Exit Code Handling Tests
| Test | Purpose |
|------|---------|
| `TestRunJobFailsOnNonZeroExitCode` | Verifies that a nonzero process exit code results in "Failed" status with an "exit code N" detail. |
| `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. |
#### Timeout
#### Start-Only Mode Tests
| 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. |
| `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). |
#### Start-only mode
#### Utility Function Tests
| 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. |
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
| 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. |
---
### src/runner/runner_windows_test.go
### autostart_windows_test.go
**Location:** `src/core/autostart_windows_test.go`
**Package:** `core`
**Build Tags:** `//go:build windows` (Windows only)
**Location:** `src/runner/runner_windows_test.go`
**Build Tags:** `//go:build windows`
Tests the Windows shell invocation and hidden-window flags.
Tests Windows autostart entry creation via shortcuts in the Startup folder.
| 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. |
| `TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension` | Regression: the program path ends at the *earliest* extension match sitting at a token boundary — not the first extension in `.exe`/`.cmd`/`.bat`/`.com` list order, and not a substring inside another word — so a `.bat` wrapper followed by an `.exe` argument still quotes only the wrapper. |
| `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. |
---
### src/runner/seed_test.go
### autostart_linux_test.go
**Location:** `src/core/autostart_linux_test.go`
**Package:** `core`
**Build Tags:** `//go:build linux` (Linux only)
**Package:** `runner`
Tests `SeedStats`, which rebuilds aggregate run statistics from the `.log` files
on disk at startup.
Tests Linux autostart entry creation via XDG Desktop Entry files.
| 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 and files within the limit are retained. |
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
| `TestCleanupLogsZeroLimitsDisableBothPolicies` | Verifies that setting both limits to zero disables both the age and count cleanup policies. |
---
### src/runner/logfile_test.go
**Package:** `runner`
Tests the disambiguating suffix `writeRunLog` applies when two runs land on
the same second.
| Test | Purpose |
|------|---------|
| `TestUniqueLogPathAvoidsCollision` | Verifies repeated calls for the same file name return distinct paths instead of silently overwriting an existing log. |
---
### src/platform/autostart/autostart_windows_test.go
**Location:** `src/platform/autostart/autostart_windows_test.go`
**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, handles quote marks, and matches paths containing spaces. |
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
| `TestCreateStartupShortcutWithoutTrayFlag` | Verifies that autostart shortcuts omit `--start-in-tray` when the tray setting is off. |
| `TestAutostartStatusRequiresMatchingTrayFlag` | Verifies `AutostartStatus` reports a problem when the shortcut arguments do not match `KeepRunningInTray`. |
---
### 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. |
| `TestLinuxAutostartWithoutTrayFlag` | Verifies that the desktop entry omits `--start-in-tray` when the tray setting is off. |
---
### src/ui/tray_test.go
**Package:** `ui`
Tests startup helpers for tray and autostart interaction.
| Test | Purpose |
|------|---------|
| `TestResolveStartHiddenUsesDomainHelper` | Verifies the UI startup helper stays aligned with `domain.ResolveStartHidden`. |
---
### 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. |
| `TestFilteredJobIndexes` | Table: verifies the "All" filter returns every index, a named folder returns only its own jobs, "No folder" matches empty and blank folder fields, and an empty job list yields no indexes. |
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
---
### src/ui/jobs_view_state_test.go
**Package:** `ui`
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
folder filter, and the ID-based selection. No Fyne app is built — the state
touches no widgets, so these run in milliseconds.
| Test | Purpose |
|------|---------|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
---
### src/ui/history_view_test.go
**Package:** `ui`
Tests the History tab: the pure activity helpers and the sorted-snapshot and
column-width behaviour of the assembled table.
| Test | Purpose |
|------|---------|
| `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. |
| `TestHistoryLogCapsRecords` | Regression guard for the unbounded History list: the log keeps the newest `maxHistoryRows` records, drops the oldest from the front, and trims a list handed in already over the cap. |
| `TestHistoryLogWidthsMatchAFullScan` | Verifies the incremental column widths equal a full rescan while every measured record is still present — the cheaper path must not clip what the old one showed. |
| `TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut` | Verifies a column keeps its width after the record that set it is dropped by the cap, since the rows on screen were laid out against it. |
| `TestHistoryLogRescansOnThemeChange` | Verifies a theme change falls back to a full rescan, the one case the incremental fold cannot handle because every stored width was measured at the old text size. |
---
### 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. |
| `TestCancelRowOverlapAddsBackOneInnerPadding` | Verifies that `cancelRowOverlap` adds back exactly one inner padding on the top edge only, leaving width and the row below unaffected. |
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
---
### 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 and the empty legacy value yield the branded primary; only the explicit system choice yields Fyne's built-in theme. |
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the GoSentry label rather than a blank option. |
---
### 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. |
| `TestMainViewRecordStartupAddsHistoryRow` | Verifies the `recordStartup` closure `newMainView` returns appends the startup receipt to History and redraws the table, with the windowed and tray wordings `run.go` selects between. |
---
### src/ui/notify_timing_test.go
**Package:** `ui`
Tests the failure-notification timing diagnostics added in 1.0.2.
| Test | Purpose |
|------|---------|
| `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). |
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.tsv` with its header on first write and appends a row containing the job name. |
| `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. |
---
@@ -645,55 +164,20 @@ Tests the failure-notification timing diagnostics added in 1.0.2.
2. **Cross-platform** — Platform-specific tests use `//go:build` tags and `runtime.GOOS` checks to skip when not applicable.
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.
3. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly.
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.
4. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
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.
9. **Redundancy is measured, not read** — Before deleting a test as a duplicate, run both in isolation with `-coverprofile` and compare the profiles. Identical coverage alone is *not* grounds for deletion: several kept tests hit the same statements while asserting genuinely different properties (see the table below). Deletion requires identical coverage **and** assertions that are a subset of the survivor's.
5. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
---
## Look-alike tests that are kept
## Future Test Coverage Gaps
Every pair here has an identical coverage profile, so a redundancy pass will
flag them again. They were measured under principle 9 and kept because the
assertions differ — not because nobody looked.
| Tests | Why both stay |
|-------|---------------|
| `TestSetGlobalPausePersistsToConfigFile` / `TestSetGlobalPauseUpdatesRuntimesAndEmits` | The first asserts the flag reaches `gosentry.json`, which is what makes the pause survive a restart; the second asserts the in-memory runtimes and the emitted event. |
| `TestRunDueQueueDrainsMultipleOverlaps` / `TestRunDueQueueRerunsAfterFinish` | The first drains three queued occurrences rather than one, so it is the test that would catch a drain loop that fires only once. |
| `TestCreateStartupShortcutHandlesCyrillicPath` / `TestCreateStartupShortcutHandlesSpaces` | Non-ASCII paths and paths with spaces are different real-world failure modes for the WScript.Shell COM call. |
| `TestRunJobLogFileAllHeaders` / `TestRunJobRecordFields` / `TestRunJobWritesLogFile` | Three different subjects: the log file's headers, the returned `RunRecord`'s fields, and the log file's name and directory. The fixtures differ too — only `TestRunJobWritesLogFile` runs the `Manual` trigger. Merging them into one `RunJob` call was measured and declined: it saves ~90 ms (the three cost 0.14 s combined; the `runner` package's seconds are `TestRunJobTimesOut` and `TestRunJobZeroTimeoutMeansNoTimeout`, which wait on purpose) and would drop the `Manual` path from the header assertions. |
---
## Remaining Test Coverage Gaps
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
### Functions deliberately at 0%
A coverage run over the non-UI packages reports these as uncovered. All are
intentional; none is an oversight to be "fixed" with a test.
- The real `Clock` — a fake is injected everywhere it is used.
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Open` — process entry points, exercised by running the app.
- The autostart and desktop-icon wrappers (`app.Service.InstallDesktopIcon`, `AutostartStatus`, `ApplyAutostart`) — OS integration, driven only on a real desktop.
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
- `app.Service.Config` and `app.Service.Paths` — read only from `src/ui`, which
this run excludes, so they are covered by the suite but not by this profile.
The same applies to `storage.Store.SaveJobs`: the engine writes through
`PrepareSaveJobs`, and the one-shot wrapper is what `OpenStore` uses.
- The five `isEvent` marker methods in `app/events.go` — empty bodies that exist
only to close the `Event` interface.
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

+10 -11
View File
@@ -3,39 +3,38 @@ module gitea.mixdep.ru/mix/gosentry
go 1.22
require (
fyne.io/fyne/v2 v2.7.4
fyne.io/fyne/v2 v2.6.3
github.com/robfig/cron/v3 v3.0.1
go.yaml.in/yaml/v4 v4.0.0-rc.5
)
require (
fyne.io/systray v1.12.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // 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/fyne-io/oksvg v0.1.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.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.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/rivo/uniseg v0.2.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/rymdport/portal v0.4.1 // 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.11.1 // indirect
github.com/stretchr/testify v1.10.0 // 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
+22 -24
View File
@@ -1,16 +1,16 @@
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=
fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
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/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/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.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
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.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=
@@ -19,18 +19,18 @@ 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/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.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.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/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.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
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/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
@@ -45,8 +45,6 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9
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=
@@ -57,20 +55,20 @@ github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
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/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/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/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/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
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/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=
-9
View File
@@ -1,10 +1 @@
// 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"
+1 -3
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/app/version.go | tr -d '\r')"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
@@ -18,14 +18,12 @@ 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/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
+1 -1
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/app/version.go | tr -d '\r')"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
mkdir -p "$(dirname "$output")"
+1 -3
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/app/version.go | tr -d '\r')"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
@@ -88,13 +88,11 @@ 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"
+3 -18
View File
@@ -6,28 +6,21 @@ 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\.."
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.
@@ -48,23 +41,15 @@ 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.
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
)
if errorlevel 1 exit /b 1
REM Icons are embedded into the executable, so no assets directory is copied next
REM to the binary. Runtime JSON and log files are created by the app itself.
echo.
echo Successfully built: %OUTPUT%
REM to the binary. Runtime YAML and log files are created by the app itself.
echo Built %OUTPUT%
-100
View File
@@ -1,100 +0,0 @@
#!/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
-39
View File
@@ -1,39 +0,0 @@
# Measures the latency of Fyne's Windows toast path: write a short PowerShell
# script to %TEMP% and run it via PowerShell -ExecutionPolicy Bypass, the same
# approach fyne.io/fyne/v2/app uses in app_windows.go SendNotification.
param(
[int]$Iterations = 3
)
$template = @'
$title = "GoSentry timing test"
$content = "benchmark"
$iconPath = "file:///"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
$toastXml = [xml] $template.GetXml()
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($toastXml.OuterXml)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ru.mixeme.gosentry.desktop").Show($toast);
'@
Write-Host "Fyne-style Windows toast latency ($Iterations run(s), no icon path):"
$totalMs = 0
for ($i = 1; $i -le $Iterations; $i++) {
$scriptPath = Join-Path $env:TEMP ("fyne-timing-test-$i.ps1")
Set-Content -Path $scriptPath -Value $template -Encoding UTF8
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$launch = "(Get-Content -Encoding UTF8 -Path `"$scriptPath`" -Raw) | Invoke-Expression"
& PowerShell -ExecutionPolicy Bypass -Command $launch | Out-Null
$sw.Stop()
$ms = [int]$sw.ElapsedMilliseconds
$totalMs += $ms
Write-Host (" run {0}: {1} ms" -f $i, $ms)
Remove-Item $scriptPath -ErrorAction SilentlyContinue
}
$avg = [math]::Round($totalMs / [double]$Iterations)
Write-Host (" average: {0} ms" -f $avg)
-54
View File
@@ -1,54 +0,0 @@
#!/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
@@ -1,41 +0,0 @@
@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%
-19
View File
@@ -1,26 +1,7 @@
@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 This file is UTF-8 (the ✓/✗ below). cmd.exe reads batch files in the
REM console's active code page, which defaults to the system locale (e.g.
REM CP866 on Russian Windows) rather than UTF-8, so without this the two
REM symbols render as mojibake. Switching the console to UTF-8 first fixes
REM that; >nul silences chcp's own "Active code page" confirmation line.
chcp 65001 >nul
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
REM expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH.
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 (
+3 -27
View File
@@ -4,12 +4,9 @@ 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 an
// Event handed to an Observer is always one of the types declared here — a
// caller outside this package cannot manufacture a new one. Go's type switch
// has no exhaustiveness check, so sealing buys that guarantee, not a
// compile-time warning when a new event type is added and a listener forgets
// to handle it; the listener still has to be updated by hand.
// 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 —
@@ -44,30 +41,9 @@ 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
+6
View File
@@ -33,6 +33,12 @@ func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
}
}
func TestEmitWithNoObserversIsNoop(t *testing.T) {
svc := newTestService(nil)
// Must not panic with an empty observer list.
svc.emit(JobChanged{})
}
// Observers may read Service state from within OnEvent without deadlocking,
// because emit is called outside the state lock.
func TestObserverCanReadServiceState(t *testing.T) {
+9 -67
View File
@@ -2,7 +2,6 @@ package app
import (
"fmt"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
@@ -33,19 +32,6 @@ func EventText(e domain.RunRecord) string {
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 {
@@ -64,6 +50,15 @@ func DisplayArguments(arguments string) string {
return strings.TrimSpace(arguments)
}
// DisplaySuccessExitCodes formats a job's success exit codes for display:
// "0" (the default) if empty, else the trimmed codes.
func DisplaySuccessExitCodes(codes string) string {
if strings.TrimSpace(codes) == "" {
return "0"
}
return strings.TrimSpace(codes)
}
// DisplayRunMode formats a job's execution mode: "Start only" or
// "Wait for completion".
func DisplayRunMode(job domain.Job) string {
@@ -82,59 +77,6 @@ func DisplayInvocation(job domain.Job) string {
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
}
// DisplayStats returns a one-line execution-time summary for a job runtime,
// with the queued-run depth appended whenever the "queue" overlap policy has
// deferred runs waiting (see maxPendingRuns). Returns "No runs recorded" when
// no runs have been counted yet, still showing the queue depth if one exists.
func DisplayStats(rt *domain.JobRuntime) string {
if rt == nil || rt.RunCount == 0 {
if rt != nil && rt.PendingRuns > 0 {
return "No runs recorded" + pendingRunsSuffix(rt.PendingRuns)
}
return "No runs recorded"
}
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) +
pendingRunsSuffix(rt.PendingRuns)
}
// pendingRunsSuffix formats the queued-run depth for DisplayStats, empty when
// nothing is queued.
func pendingRunsSuffix(pending int) string {
if pending <= 0 {
return ""
}
return fmt.Sprintf(", %d queued", pending)
}
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
// 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 {
+9 -94
View File
@@ -68,6 +68,15 @@ func TestDisplayArguments(t *testing.T) {
}
}
func TestDisplaySuccessExitCodes(t *testing.T) {
if got := DisplaySuccessExitCodes(" "); got != "0" {
t.Errorf("empty codes = %q, want %q", got, "0")
}
if got := DisplaySuccessExitCodes(" 0,1 "); got != "0,1" {
t.Errorf("codes = %q, want %q", got, "0,1")
}
}
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")
@@ -98,97 +107,3 @@ func TestDisplayIndex(t *testing.T) {
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)
}
// A "queue" overlap backlog is appended to whichever form applies, so it stays
// visible even before the first run has completed.
if got, want := DisplayStats(&domain.JobRuntime{PendingRuns: 2}), "No runs recorded, 2 queued"; got != want {
t.Errorf("DisplayStats pending, no runs = %q, want %q", got, want)
}
rtPending := &domain.JobRuntime{RunCount: 5, FailCount: 2, LastDurationMS: 450, AvgDurationMS: 380, MaxDurationMS: 520, PendingRuns: 3}
wantPending := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms, 3 queued"
if got := DisplayStats(rtPending); got != wantPending {
t.Errorf("DisplayStats pending = %q, want %q", got, wantPending)
}
}
func TestEventLine(t *testing.T) {
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)
}
}
+150 -198
View File
@@ -1,15 +1,14 @@
package app
import (
"context"
"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
@@ -43,26 +42,12 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
s.parseScheduleLocked(&job)
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err := save(); err != nil {
// The write is atomic, so a failure left the file holding the previous
// list: take the job back out so memory matches what is on disk. Another
// operation may have run in between, so it is removed by ID rather than by
// truncating the slice.
s.mu.Lock()
if index := s.indexByIDLocked(job.ID); index >= 0 {
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
}
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return job, nil
return job, err
}
// UpdateJob replaces the durable configuration of the job with the same ID,
@@ -95,15 +80,12 @@ func (s *Service) UpdateJob(job domain.Job) error {
s.refreshNextRunLocked(existing, runtime)
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
prependLog(runtime, record)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return nil
return err
}
// DeleteJob removes the job with the given ID along with its runtime and cached
@@ -121,15 +103,12 @@ func (s *Service) DeleteJob(id int) error {
delete(s.runtimes, id)
delete(s.schedules, id)
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: 0})
return nil
return err
}
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
@@ -156,181 +135,175 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
runtime.NextDue = time.Time{}
// A disabled job's own occurrences stop firing, so a "queue" backlog it was
// carrying no longer corresponds to anything: clear it rather than replaying
// stale deferred runs if the job is re-enabled later.
runtime.PendingRuns = 0
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: id})
return nil
return err
}
// 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.
// SetGlobalPause flips the global pause that gates all execution, scheduled and
// manual. Each enabled job's next-run text reflects the new state immediately so
// the list view is understandable before the next tick. A "Paused"/"Resumed"
// scheduler activity record and a SchedulerStateChanged event are emitted.
func (s *Service) SetGlobalPause(paused bool) error {
s.mu.Lock()
s.paused = paused
s.store.Config.Paused = paused
now := time.Now()
for index := range s.jobs {
job := &s.jobs[index]
runtime := s.runtimeForLocked(job)
if paused {
// A "queue" backlog counts occurrences missed *while paused is off*; once
// paused, none of those correspond to anything the user would expect
// replayed on resume, so drop it rather than letting a stale counter fire
// a deferred run for an occurrence from before the pause.
runtime.PendingRuns = 0
}
s.refreshNextRunFromLocked(job, runtime, now)
}
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
if err := save(); err != nil {
return err
}
state, detail := "Resumed", "All job execution resumed"
if paused {
state, detail = "Paused", "All job execution paused"
}
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
s.emit(SchedulerStateChanged{Paused: paused})
return nil
return err
}
// 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
}
// RunNow starts a manual run of a job. It refuses to run while globally paused —
// the pause is an emergency stop for all execution — and will not start a job
// that is already running. 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()
if s.store.Config.JobListView == view {
if s.paused {
s.mu.Unlock()
return nil
return errors.New("scheduler is paused")
}
s.store.Config.JobListView = view
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
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)
}
err := s.startRunLocked(job, runtime, "Manual")
s.mu.Unlock()
return save()
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
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 {
// 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. At most one job is started
// per call so scheduled shell commands in this single process do not overlap; a
// job already running is skipped. 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.
func (s *Service) RunDue(now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Config.NotifyOnFailure
var startedID int
if !s.paused {
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" {
continue
}
// Async save errors cannot be returned to a caller here; surfacing them
// is deferred to T5.1 with the rest of the swallowed saves.
_ = s.startRunLocked(job, runtime, "Schedule")
startedID = job.ID
break
}
}
s.mu.Unlock()
if startedID != 0 {
s.emit(JobChanged{JobID: startedID})
}
}
// UpdateSettings validates and persists a new application configuration. The
// loaded jobs are re-saved because the jobs file may have changed, and log
// loaded jobs are re-saved because the jobs directory 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.
// Autostart is intentionally left to the caller until T5.2 introduces an
// injectable autostart.Manager.
func (s *Service) UpdateSettings(config domain.Config) error {
if err := validateConfig(config); err != nil {
return err
}
// The path is stored exactly as it is resolved, so a hand-typed value with
// stray spaces cannot make the saved setting and the file in use disagree.
config.JobsFile = strings.TrimSpace(config.JobsFile)
s.mu.Lock()
// AppDir is fixed for the process and only UpdateSettings itself — a UI
// action — can move JobsPath, so this snapshot stays valid across the reads
// below.
appDir := s.store.Paths.AppDir
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
switching := jobsPath != s.store.Paths.JobsPath
running := s.anyRunningLocked()
s.mu.Unlock()
if switching && running {
return errors.New("cannot change the jobs file while a job is running")
}
// Read the new file, and reconstruct its jobs' statistics from the logs the
// new config points at, before anything is written and while no lock is held:
// both are file I/O, and SeedStats opens every log in the directory. A file
// that cannot be parsed leaves both the config and the current jobs untouched.
var adopted []domain.Job
var seeds map[int]runner.SeededStats
if switching {
jobs, found, err := storage.LoadJobsFile(jobsPath)
if err != nil {
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
}
if found {
adopted = jobs
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
}
}
s.mu.Lock()
// The guard above was evaluated before the reads, off the lock, so re-check
// it: a scheduled run may have started in the meantime, and adoption drops
// every runtime.
if switching && s.anyRunningLocked() {
s.mu.Unlock()
return errors.New("cannot change the jobs file while a job is running")
}
s.store.Config = config
saveConfig := s.store.PrepareSaveConfig()
if adopted != nil {
s.adoptJobsLocked(adopted)
s.applySeededStatsLocked(seeds)
if err := s.store.SaveConfig(); err != nil {
s.mu.Unlock()
return err
}
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
// the (possibly new) jobs directory and cleanup targets the new logs dir.
if err := s.store.SaveJobs(s.jobs); err != nil {
s.mu.Unlock()
return err
}
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
// write targets the (possibly new) jobs file and cleanup targets the new logs
// dir. Adopted jobs are written back too, which persists the IDs and defaults
// that normalization filled in, exactly as loading them at startup would. The
// jobs write is skipped when the config write fails, because both writes run
// in the order prepared and stop at the first error.
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
loaded := len(s.jobs)
logsDir := s.store.Paths.LogsDir
maxFiles := s.store.Config.MaxLogFiles
maxAge := s.store.Config.MaxLogAgeDays
s.mu.Unlock()
saveErr := save()
if adopted != nil {
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
// History which file those jobs came from, since nothing was asked. Both
// are emitted even when the write failed: the adopted jobs are already the
// in-memory list, and a job list the user cannot see would be worse than
// the error they are about to be shown.
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
s.emit(JobChanged{})
}
if saveErr != nil {
return saveErr
}
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
}
// startRunLocked transitions a job to "Running", persists that, and launches the
// run on a background goroutine. The caller must hold mu.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string) error {
jobCopy := *job
runtime.LastState = "Running"
runtime.NextRun = "Running"
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
runtime.NextDue = time.Time{}
err := s.store.SaveJobs(s.jobs)
// 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)
return err
}
// executeRun runs the job off the lock, then records the result back through the
// Service under the lock and announces it. It runs on its own goroutine.
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) {
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
s.mu.Lock()
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)
s.refreshNextRunLocked(current, runtime)
// Async save errors cannot be returned to a caller; surfacing them is
// deferred to T5.1 along with the rest of the swallowed saves.
_ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
_ = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: jobCopy.ID})
}
// 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) {
@@ -446,6 +419,25 @@ func uiRecord(jobID int, jobName string, state string, detail string) domain.Run
}
}
// 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\nsuccess_exit_codes:\n")
builder.WriteString(runner.SuccessExitCodesText(job))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
// 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) {
@@ -454,6 +446,10 @@ func normalizeJob(job *domain.Job) {
job.Schedule = strings.TrimSpace(job.Schedule)
job.Command = strings.TrimSpace(job.Command)
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
}
// validateJob enforces the minimum executable definition: name, schedule, and
@@ -464,66 +460,22 @@ 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.JobsDir) == "" {
return errors.New("jobs directory is required")
}
if strings.TrimSpace(config.LogsDir) == "" {
return errors.New("logs directory is required")
}
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
if config.MaxLogFiles < 0 {
return errors.New("max log files must be zero (unlimited) or a positive number")
if config.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 zero (unlimited) or a positive number")
}
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
return errors.New("execution mode must be 'parallel' or 'sequential'")
}
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
return errors.New("overlap policy must be 'skip' or 'queue'")
}
if config.DefaultTimeoutSeconds < 0 {
return errors.New("default timeout must not be negative (0 means no timeout)")
}
// Empty Theme is accepted and normalized to the branded theme on load, so
// older configs (and hand-built ones) stay valid without an explicit theme.
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
return errors.New("theme must be 'system' or 'gosentry'")
if config.MaxLogAgeDays <= 0 {
return errors.New("max log age days must be a positive number")
}
return nil
}
+32 -508
View File
@@ -2,11 +2,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
@@ -24,12 +20,12 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir,
ConfigPath: filepath.Join(dir, "gosentry.json"),
ConfigPath: filepath.Join(dir, "gosentry.yaml"),
JobsDir: dir,
JobsPath: filepath.Join(dir, "jobs.json"),
JobsPath: filepath.Join(dir, "jobs.yaml"),
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},
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30},
}
return NewService(store, jobs)
}
@@ -102,16 +98,6 @@ func TestCreateJobValidates(t *testing.T) {
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) {
@@ -219,23 +205,6 @@ func TestSetEnabledToggles(t *testing.T) {
}
}
// TestSetEnabledClearsPendingRuns verifies that disabling a job drops any
// "queue" overlap backlog it was carrying, so re-enabling it later does not
// replay a deferred run for an occurrence that fired before the disable.
func TestSetEnabledClearsPendingRuns(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
svc.runtimes[1].PendingRuns = 2
svc.mu.Unlock()
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.PendingRuns != 0 {
t.Errorf("PendingRuns after disable = %d, want 0", rt.PendingRuns)
}
}
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
svc := newTempService(t, []domain.Job{
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
@@ -276,11 +245,11 @@ 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) {
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
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
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
@@ -324,11 +293,11 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
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) {
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
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
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := make(chan struct{}, 1)
svc.Subscribe(ObserverFunc(func(e Event) {
@@ -364,27 +333,21 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
}
}
func TestRunNowAllowedWhilePaused(t *testing.T) {
func TestRunNowRefusedWhilePaused(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
var ran bool
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
ran = true
return domain.RunRecord{}
}
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)
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while paused")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("runner was not invoked for a manual run while paused")
if ran {
t.Error("runner must not be invoked while paused")
}
}
@@ -392,11 +355,11 @@ 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) {
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
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
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
@@ -423,9 +386,9 @@ func TestRunDueStartsDueJob(t *testing.T) {
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) {
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
return domain.RunRecord{}
}
// Next-due is ~1m out, so nothing is due "now".
@@ -436,41 +399,12 @@ func TestRunDueSkipsJobNotYetDue(t *testing.T) {
}
}
// 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) {
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
return domain.RunRecord{}
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
@@ -498,16 +432,16 @@ 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) {
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}, nil
return domain.RunRecord{State: "Success"}
}
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
svc.StartWith(clock)
svc.Start(clock)
defer svc.Stop()
clock.ticks <- clock.now
@@ -522,9 +456,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = -1
bad.MaxLogFiles = 0
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for negative max log files")
t.Error("expected validation error for non-positive max log files")
}
good := svc.store.Config
@@ -533,21 +467,8 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.store.Config)
}
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
// invalid value, so it must be accepted and persisted rather than rejected
// or silently backfilled.
unlimited := svc.store.Config
unlimited.MaxLogFiles = 0
unlimited.MaxLogAgeDays = 0
if err := svc.UpdateSettings(unlimited); err != nil {
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
}
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.Store().Config)
}
}
@@ -559,12 +480,10 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
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 jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -577,265 +496,6 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
}
}
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)
}
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
// files the new configuration points at. That scan opens every log in the
// directory, so UpdateSettings runs it before taking the state lock; this pins
// that its result still reaches the runtime map.
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
logsDir := svc.store.Paths.LogsDir
if err := os.MkdirAll(logsDir, 0o755); err != nil {
t.Fatal(err)
}
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
t.Fatal(err)
}
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
runtime := svc.Runtime(7)
if runtime == nil {
t.Fatal("the adopted job has no runtime")
}
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
}
}
// Job saves run after mu is released, so one operation can be writing while
// another mutates state. deferSaveLocked takes its own lock while mu is still
// held, which is what keeps writes in mutation order: whatever changed the list
// last also wrote it last, so the file ends up matching memory instead of
// holding an older snapshot.
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
svc := newTempService(t, nil)
const workers = 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Errorf("CreateJob %d: %v", i, err)
return
}
if err := svc.SetEnabled(job.ID, false); err != nil {
t.Errorf("SetEnabled %d: %v", job.ID, err)
}
}(i)
}
wg.Wait()
memory := svc.Jobs()
if len(memory) != workers {
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
}
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
if err != nil || !found {
t.Fatalf("read jobs file: found=%v err=%v", found, err)
}
if len(saved) != len(memory) {
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
}
onDisk := make(map[int]domain.Job, len(saved))
for _, job := range saved {
onDisk[job.ID] = job
}
for _, job := range memory {
got, ok := onDisk[job.ID]
if !ok {
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
continue
}
if got.Name != job.Name || got.Enabled != job.Enabled {
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
}
}
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
@@ -845,139 +505,3 @@ func TestPrependLogCapsActivityList(t *testing.T) {
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")
}
}
-54
View File
@@ -1,54 +0,0 @@
package app
import (
"fmt"
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
)
// InstallDesktopIcon installs the application's .desktop file and icon on
// Linux (no-op on other platforms). The resulting icon path is stored in
// store.Paths.DesktopIcon so ApplyAutostart can reference it. A failure is
// reported through ErrorOccurred rather than discarded, so the visible symptom
// (a generic dock icon) has an explanation in History instead of none.
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes)
if err != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
return
}
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
}
// AutostartStatus reports whether the platform autostart entry matches the
// current StartOnLogin and KeepRunningInTray settings in the stored config.
func (s *Service) AutostartStatus() (ok bool, message string) {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
manager := s.manager
s.mu.Unlock()
if manager == nil {
return false, "autostart not available"
}
return manager.Status(enabled, startInTray, execPath)
}
// ApplyAutostart writes or removes the platform autostart entry to match the
// current StartOnLogin and KeepRunningInTray settings. Call after UpdateSettings.
func (s *Service) ApplyAutostart() error {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
iconPath := s.store.Paths.DesktopIcon
manager := s.manager
s.mu.Unlock()
if manager == nil {
return nil
}
return manager.Set(enabled, startInTray, execPath, iconPath)
}
-275
View File
@@ -1,275 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner"
)
// maxPendingRuns bounds how many missed occurrences the "queue" overlap policy
// will defer for one job. Without a ceiling a job whose runs take longer than
// its interval would queue one more occurrence on every tick forever, so once
// the cap is reached further overlaps are dropped exactly as the "skip" policy
// would drop them, until the backlog drains below the cap again.
const maxPendingRuns = 10
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
// automatic runs (see RunDue), so a manual "Run now" is allowed even while
// paused — it is the user's explicit, one-off action. It will not start a job
// 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)")
}
s.startRunLocked(job, runtime, "Manual", time.Now())
s.mu.Unlock()
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
return nil
}
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
// 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
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 < maxPendingRuns {
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
}
s.startRunLocked(job, runtime, "Schedule", now)
started = append(started, job.ID)
running = true
}
}
s.mu.Unlock()
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, and launches the run on a background goroutine. Neither
// step touches a durable field — both live on JobRuntime, which is never
// persisted — so there is nothing to save here. Advancing (rather than zeroing)
// NextDue keeps the schedule marching while the run is in flight, which is what
// lets RunDue notice a fresh occurrence firing during a long run and apply the
// overlap policy. The caller must hold mu. now is the reference time for
// next-due advancement and the running placeholder.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) {
jobCopy := *job
runtime.LastState = "Running"
runtime.NextRun = "Running"
runtime.Output = runningOutput(jobCopy, trigger, now)
s.advanceNextDueLocked(job, runtime, now)
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)
}
// 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 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.
s.startRunLocked(current, runtime, "Schedule", time.Now())
rerunStarted = true
} else {
s.refreshNextRunLocked(current, runtime)
}
}
s.mu.Unlock()
// Cleanup is a directory scan plus up to MaxLogFiles unlinks. It needs only
// the values already snapshotted into runEnv, so it runs after mu is released
// rather than making every UI refresh wait behind it. It runs even when the
// job is gone, because the run still wrote a log file that retention covers.
cleanupErr := runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
if logErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
}
if cleanupErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
}
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.DurationSumMS += r.DurationMS
rt.AvgDurationMS = rt.DurationSumMS / 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()
}
-630
View File
@@ -1,630 +0,0 @@
package app
import (
"context"
"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)
}
// AvgDurationMS must always be exactly DurationSumMS/TimedRunCount — a stored
// sum divided once, not an incremental mean that truncates on every step and
// compounds error over a long-running job.
if rt.DurationSumMS != 700 {
t.Errorf("DurationSumMS = %d, want 700", rt.DurationSumMS)
}
if want := rt.DurationSumMS / int64(rt.TimedRunCount); rt.AvgDurationMS != want {
t.Errorf("AvgDurationMS = %d, want DurationSumMS/TimedRunCount = %d", rt.AvgDurationMS, want)
}
}
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
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},
})
// Empty per-job OverlapPolicy inherits the global queue policy.
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
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)
}
}
// TestRunDueQueueCapsPendingRuns verifies that a job whose runs never keep up
// with its schedule stops accumulating PendingRuns at maxPendingRuns instead of
// growing without bound.
func TestRunDueQueueCapsPendingRuns(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
t.Cleanup(func() {
close(release)
waitRecord(t, done)
})
primeDue(t, svc, 1)
svc.RunDue(time.Now())
// Far more due ticks than the cap while the first run stays in flight.
for range maxPendingRuns + 5 {
primeDue(t, svc, 1)
svc.RunDue(time.Now())
}
svc.mu.Lock()
pending := svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != maxPendingRuns {
t.Fatalf("PendingRuns = %d, want capped at %d", pending, maxPendingRuns)
}
}
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
// "queue" policy queues a re-run even though the global default is "skip": the
// effective policy is resolved per job, so the job-level value wins.
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)
}
}
// 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)
}
// TestRunDueQueueDrainSkippedWhenPaused verifies that queued overlap runs are not
// drained while the scheduler is globally paused, and that pausing clears the
// backlog rather than leaving it to fire a stale deferred run on resume.
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
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)
}
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("pausing must clear a queued backlog, PendingRuns = %d, want 0", pending)
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
svc.mu.Lock()
pending = svc.runtimes[1].PendingRuns
svc.mu.Unlock()
if pending != 0 {
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 0", pending)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// 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)
}
}
+37 -123
View File
@@ -6,7 +6,6 @@ import (
"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"
@@ -18,18 +17,22 @@ import (
// 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.
// State ownership and the locking contract were established in T3.1; the
// event/observer machinery in T3.2. T3.3 added the state-mutating intents
// (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause,
// UpdateSettings) in operations.go: the Service is the sole writer of job and
// runtime state, persisting through the store and announcing changes via events.
//
// T3.4 makes the Service drive scheduling too. It owns the timing loop through a
// scheduler.Scheduler that calls RunDue on every tick; the scheduler holds no
// job state and never touches the slice directly. The old shared *[]domain.Job
// between GUI and scheduler is gone — both go through the Service.
//
// 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. Blocking file I/O follows the same rule: mu is the lock the
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
// log-directory scan, or a pass over every log header must not happen inside it
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
// mu is released.
type Service struct {
mu sync.Mutex
store *storage.Store
@@ -47,7 +50,7 @@ type Service struct {
// 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)
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
ctx context.Context
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
@@ -55,17 +58,6 @@ type Service struct {
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
// saveMu serializes the store writes that operations prepare under mu and run
// after releasing it. It is taken while mu is still held and released once the
// write is done, so writes reach the file in the same order their snapshots
// were taken and an older snapshot can never land on top of a newer one.
// Nothing may take mu while holding saveMu.
saveMu sync.Mutex
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
@@ -73,98 +65,37 @@ type Service struct {
observers []Observer
}
// deferSaveLocked prepares the store writes for the caller to run after mu is
// released, and takes saveMu now so a later operation's write cannot overtake
// this one. The caller must hold mu, must unlock it before calling the returned
// function, and must call that function exactly once. Keeping the marshal, the
// fsync, and the rename out of the critical section is what stops a settings
// change or a job edit from blocking a scheduler tick or a finishing run. The
// writes run in the order given and stop at the first error.
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
s.saveMu.Lock()
return func() error {
defer s.saveMu.Unlock()
for _, write := range writes {
if err := write(); err != nil {
return err
}
}
return nil
}
}
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
// 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,
store: store,
jobs: jobs,
runtimes: domain.NewRuntimes(jobs),
schedules: make(map[int]domain.Schedule, len(jobs)),
runJob: runner.RunJob,
ctx: context.Background(),
}
// No lock is needed here: construction is single-threaded, before Start
// launches the timing loop.
s.adoptJobsLocked(jobs)
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
return s
}
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
// derived from it: the runtime map, the parsed-schedule cache, and each job's
// first next-run — so the Service is ready to schedule the moment it exists,
// mirroring the old scheduler's reset-on-construction.
//
// It backs both construction and a Settings change that points at a different
// jobs file. Statistics seeded from existing log files are applied separately by
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
// must hold mu.
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.jobs = jobs
s.runtimes = domain.NewRuntimes(jobs)
s.schedules = make(map[int]domain.Schedule, len(jobs))
// Parse every schedule once, then compute 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. No lock is needed: construction is
// single-threaded, before Start launches the timing loop.
now := time.Now()
for index := range s.jobs {
job := &s.jobs[index]
s.parseScheduleLocked(job)
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
}
return s
}
// applySeededStatsLocked folds statistics reconstructed from existing log files
// into the runtime map, so the details panel shows accumulated run history
// immediately rather than only runs since this process started. It is separate
// from adoptJobsLocked because producing the seeds opens every log file in the
// directory, which must not happen under mu: callers compute the map first and
// apply it here. The caller must hold mu.
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
for id, seed := range seeds {
runtime := s.runtimes[id]
if runtime == nil {
continue
}
runtime.RunCount = seed.RunCount
runtime.FailCount = seed.FailCount
runtime.LastDurationMS = seed.LastDurationMS
runtime.AvgDurationMS = seed.AvgDurationMS
runtime.MaxDurationMS = seed.MaxDurationMS
runtime.TimedRunCount = seed.TimedRunCount
runtime.DurationSumMS = seed.DurationSumMS
}
}
// 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) {
// Start begins scheduling. It installs a cancelable run context and a timing
// loop driven by the given clock; every tick calls RunDue. Pass
// scheduler.NewRealClock() in production. Start is expected once, during setup,
// before any concurrent use.
func (s *Service) Start(clock scheduler.Clock) {
s.mu.Lock()
ctx, cancel := context.WithCancel(context.Background())
s.ctx = ctx
@@ -200,32 +131,14 @@ func Open() (*Service, error) {
if err != nil {
return nil, err
}
svc := NewService(store, jobs)
svc.manager = autostart.New()
return svc, nil
return NewService(store, jobs), nil
}
// Config returns a copy of the current application configuration, safe to
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
// are the only writers and all mutate store.Config under mu; copying under the
// same lock is what keeps a UI read from racing them, instead of holding onto
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
// Service state through typed events and accessors, never shared mutable
// state).
func (s *Service) Config() domain.Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Config
}
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
// so this copies under the same lock as Config for the same reason.
func (s *Service) Paths() storage.Paths {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Paths
// 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
@@ -242,8 +155,9 @@ func (s *Service) Jobs() []domain.Job {
// 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.
// are only safe while no concurrent mutation is in flight. The scheduler now
// drives the Service rather than sharing state, so the remaining concurrent
// reader is the UI listener, which T4.1 marshals onto the main thread.
func (s *Service) Runtime(id int) *domain.JobRuntime {
s.mu.Lock()
defer s.mu.Unlock()
+8
View File
@@ -47,3 +47,11 @@ func TestJobsReturnsCopy(t *testing.T) {
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
}
}
func TestStoreReturnsWiredStore(t *testing.T) {
store := &storage.Store{}
svc := NewService(store, nil)
if svc.Store() != store {
t.Error("Store() did not return the wired store")
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build.
var Version = "1.0.3"
var Version = "0.3.5"
+10 -125
View File
@@ -5,136 +5,21 @@ package domain
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// AutostartArguments returns the command-line suffix written to a platform
// autostart entry when KeepRunningInTray is enabled. An empty string means the
// app should open its window normally after sign-in.
func AutostartArguments(keepInTray bool) string {
if keepInTray {
return StartInTrayArgument
}
return ""
}
// ResolveStartHidden reports whether an autostart launch should skip showing
// the main window. The CLI flag is ignored when KeepRunningInTray is off so a
// stale shortcut cannot hide the app with no tray icon to restore it.
func ResolveStartHidden(cliStartInTray, keepInTray bool) bool {
return cliStartInTray && keepInTray
}
// ExecutionMode controls whether due jobs run concurrently or one at a time.
type ExecutionMode string
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 (
// ThemeSystem keeps Fyne's built-in theme, following the platform look.
ThemeSystem Theme = "system"
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
// and app icon.
ThemeGoSentry Theme = "gosentry"
)
// 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
// 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 {
// 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 ThemeGoSentry so
// configs written before this field existed pick up the branded look.
Theme Theme `json:"theme,omitempty"`
// JobListView selects the Jobs list density. Empty is treated as
// JobListViewDetailed so configs written before this field existed keep the
// current three-line rows.
JobListView JobListView `json:"job_list_view,omitempty"`
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"`
}
// 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: ThemeGoSentry,
JobListView: JobListViewDetailed,
DefaultTimeoutSeconds: 0,
}
}
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
// 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 `json:"jobs"`
Jobs []Job `yaml:"jobs"`
}
-49
View File
@@ -1,49 +0,0 @@
package domain
import "testing"
func TestAutostartArguments(t *testing.T) {
if got := AutostartArguments(true); got != StartInTrayArgument {
t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument)
}
if got := AutostartArguments(false); got != "" {
t.Errorf("AutostartArguments(false) = %q, want empty", got)
}
}
func TestResolveStartHidden(t *testing.T) {
cases := []struct {
cli, keep, want bool
}{
{true, true, true},
{true, false, false},
{false, true, false},
{false, false, false},
}
for _, tc := range cases {
if got := ResolveStartHidden(tc.cli, tc.keep); got != tc.want {
t.Errorf("ResolveStartHidden(%v, %v) = %v, want %v", tc.cli, tc.keep, got, tc.want)
}
}
}
// TestJobListViewIsCompact pins the normalization rule: only the exact
// "compact" value selects the one-line rows, so empty and unrecognised values
// (including configs written before the field existed) keep the detailed look.
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)
}
}
}
+10 -25
View File
@@ -1,33 +1,18 @@
package domain
// Job is the user-visible scheduled command. It contains only durable
// configuration: every field is persisted to jobs.json. Transient execution
// configuration: every field is persisted to jobs.yaml. 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
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"`
}
+8 -9
View File
@@ -4,13 +4,12 @@ package domain
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string
JobID int
JobName string
Trigger string
State string
Detail string
LogFile string
Output string
DurationMS int64
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"`
}
+1 -27
View File
@@ -3,7 +3,7 @@ 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
// jobs.yaml: 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.
@@ -18,32 +18,6 @@ type JobRuntime struct {
// 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
// DurationSumMS is the running total of every timed run's duration.
// AvgDurationMS is always DurationSumMS/TimedRunCount, computed fresh on each
// update rather than folded incrementally — an incremental integer mean
// truncates on every step, and the error compounds over the life of a job
// that keeps running. A stored sum divided once per update matches the exact
// sum/count average runner.aggregateLogStats computes when seeding from logs,
// so the two no longer disagree about the same run history.
DurationSumMS int64
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
-11
View File
@@ -1,11 +0,0 @@
package autostart
// Manager controls platform autostart for the application.
type Manager interface {
// Set writes or removes the platform autostart entry to match enabled.
// When enabled, startInTray selects whether the entry passes --start-in-tray.
Set(enabled, startInTray bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled
// and startInTray.
Status(expectedEnabled, startInTray bool, executablePath string) (ok bool, message string)
}
+102 -29
View File
@@ -5,6 +5,7 @@ package autostart
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -12,43 +13,39 @@ import (
"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, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
const legacyAutostartDesktopFileName = "pysentry.desktop"
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
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
}
execLine := quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
execLine += " " + args
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s
Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, execLine, desktopIconLine(iconPath))
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
@@ -58,11 +55,17 @@ X-GNOME-Autostart-enabled=true
return nil
}
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
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 {
@@ -74,15 +77,9 @@ func autostartStatus(expectedEnabled bool, startInTray bool, executablePath stri
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
expectedExec += " " + args
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
if startInTray {
return false, "Autostart desktop entry does not start in tray"
}
return false, "Autostart desktop entry starts in tray while setting is off"
return false, "Autostart desktop entry points to another executable"
}
return true, "Autostart is configured"
}
@@ -99,6 +96,18 @@ func autostartDesktopPath() (string, error) {
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)
}
@@ -110,3 +119,67 @@ func desktopIconLine(iconPath string) string {
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
}
+17 -21
View File
@@ -4,6 +4,7 @@ package autostart
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -14,7 +15,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := setAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
@@ -33,29 +34,24 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
}
}
func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
func TestLinuxAutostartRemovesLegacyDesktopEntry(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := setAutostart(true, false, executablePath, ""); err != nil {
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)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry should not include tray flag: %s", data)
}
if strings.Contains(string(data), domain.StartInTrayArgument) {
t.Fatalf("desktop entry must not pass --start-in-tray: %s", data)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy desktop entry still exists or cannot be checked: %v", err)
}
}
+2 -15
View File
@@ -4,27 +4,14 @@ 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, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
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, startInTray bool, executablePath string) (bool, string) {
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
+58 -25
View File
@@ -11,40 +11,32 @@ import (
"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, startInTray bool, executablePath, iconPath string) error {
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
// Windows autostart used to write HKCU\Run values, but that approach became
// brittle once paths with spaces and the "--start-in-tray" argument entered
// the picture. A Startup-folder shortcut stores target path and arguments as
// 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
}
if enabled {
return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray))
return createStartupShortcut(shortcutPath, executablePath, iconPath)
}
return removeIfExists(shortcutPath)
}
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
shortcutPath, err := startupShortcutPath()
if err != nil {
return false, "Startup folder cannot be resolved"
@@ -52,6 +44,9 @@ func autostartStatus(expectedEnabled bool, startInTray bool, executablePath stri
_, 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 {
@@ -61,6 +56,9 @@ func autostartStatus(expectedEnabled bool, startInTray bool, executablePath stri
}
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 {
@@ -74,12 +72,8 @@ func autostartStatus(expectedEnabled bool, startInTray bool, executablePath stri
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
expectedArgs := domain.AutostartArguments(startInTray)
if strings.TrimSpace(arguments) != expectedArgs {
if startInTray {
return false, "Autostart shortcut does not start in tray"
}
return false, "Autostart shortcut starts in tray while setting is off"
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
}
return true, "Autostart is configured"
}
@@ -92,7 +86,7 @@ func startupShortcutPath() (string, error) {
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
}
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string, arguments string) error {
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
return err
}
@@ -110,7 +104,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+arguments,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
@@ -126,7 +120,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
// os.Executable, causing autostartStatus to report "shortcut points to
// os.Executable, causing AutostartStatus to report "shortcut points to
// another executable" for any install path that contains non-ASCII chars.
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
@@ -146,6 +140,11 @@ 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) {
@@ -154,6 +153,40 @@ 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")
winproc.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)
winproc.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)
@@ -11,12 +11,32 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
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)
}
}
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
t.Fatal("expected paths to match")
}
}
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
t.Fatal("expected paths with spaces to match")
}
}
func TestSameWindowsPathStripsExtendedLengthPrefix(t *testing.T) {
if !sameWindowsPath(`\\?\D:\Apps\GoSentry\gosentry.exe`, `D:\Apps\GoSentry\gosentry.exe`) {
t.Fatal("expected \\\\?\\-prefixed path to match plain path")
@@ -82,7 +102,7 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
@@ -98,51 +118,6 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
}
}
func TestCreateStartupShortcutWithoutTrayFlag(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
_, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if arguments != "" {
t.Fatalf("shortcut arguments mismatch: got %q want empty", arguments)
}
}
func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("APPDATA", tempDir)
shortcutPath, err := startupShortcutPath()
if err != nil {
t.Fatalf("startupShortcutPath: %v", err)
}
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
ok, message := autostartStatus(true, false, targetPath)
if ok {
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
}
if message != "Autostart shortcut starts in tray while setting is off" {
t.Fatalf("unexpected message: %q", message)
}
}
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
@@ -154,7 +129,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
@@ -1,62 +0,0 @@
//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)
}
}
-42
View File
@@ -1,42 +0,0 @@
// 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
}
@@ -1,9 +0,0 @@
//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}
}
@@ -1,10 +0,0 @@
//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
}
@@ -1,67 +0,0 @@
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))
}
}
@@ -1,10 +0,0 @@
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)}
}
-5
View File
@@ -9,11 +9,6 @@ import (
"time"
)
// CleanupLogs enforces the count and age retention policies on the .log files
// in logsDir. maxFiles <= 0 disables the count policy and maxAgeDays <= 0
// disables the age policy, independently — "keep everything" is a value the
// user can choose in Settings, not just an internal default (STANDARDS
// §Intentional behavior).
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
-138
View File
@@ -1,138 +0,0 @@
package runner
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func writeLogFile(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("log"), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func setModTime(t *testing.T, path string, age time.Duration) {
t.Helper()
mt := time.Now().Add(-age)
if err := os.Chtimes(path, mt, mt); err != nil {
t.Fatal(err)
}
}
func TestCleanupLogsMissingDirReturnsNil(t *testing.T) {
err := CleanupLogs(filepath.Join(t.TempDir(), "nonexistent"), 100, 30)
if err != nil {
t.Errorf("missing dir should return nil, got %v", err)
}
}
func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
dir := t.TempDir()
old := writeLogFile(t, dir, "old.log")
recent := writeLogFile(t, dir, "recent.log")
setModTime(t, old, 31*24*time.Hour) // 31 days old → past the 30-day limit
setModTime(t, recent, 5*24*time.Hour) // 5 days old → within limit
if err := CleanupLogs(dir, 100, 30); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Error("file older than maxAgeDays should be deleted")
}
if _, err := os.Stat(recent); err != nil {
t.Errorf("file within maxAgeDays should be kept: %v", err)
}
}
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
// than maxFiles log files exist the oldest (by modification time) are removed.
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
func TestCleanupLogsByCountDeletesOldest(t *testing.T) {
dir := t.TempDir()
// Create 5 files; i=0 is newest (1 day old), i=4 is oldest (5 days old).
var paths []string
for i := 0; i < 5; i++ {
path := writeLogFile(t, dir, fmt.Sprintf("job_%03d.log", i))
setModTime(t, path, time.Duration(i+1)*24*time.Hour)
paths = append(paths, path)
}
if err := CleanupLogs(dir, 3, 0); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Errorf("expected 3 files after count cleanup, got %d", len(entries))
}
// The 3 newest files (paths[0..2]) must survive.
for _, kept := range paths[:3] {
if _, err := os.Stat(kept); err != nil {
t.Errorf("newest file %s should be kept: %v", filepath.Base(kept), err)
}
}
// The 2 oldest files (paths[3..4]) must be removed.
for _, deleted := range paths[3:] {
if _, err := os.Stat(deleted); !os.IsNotExist(err) {
t.Errorf("oldest file %s should have been deleted", filepath.Base(deleted))
}
}
}
func TestCleanupLogsNonLogFilesNotDeleted(t *testing.T) {
dir := t.TempDir()
logFile := writeLogFile(t, dir, "job.log")
notALog := writeLogFile(t, dir, "notes.txt")
// Both are old enough that age-based cleanup would remove them if it applied.
setModTime(t, logFile, 35*24*time.Hour)
setModTime(t, notALog, 35*24*time.Hour)
if err := CleanupLogs(dir, 100, 30); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
t.Error("old .log file should be deleted")
}
if _, err := os.Stat(notALog); err != nil {
t.Errorf(".txt file should not be deleted: %v", err)
}
}
func TestCleanupLogsSubdirsNotDeleted(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "archive.log") // name looks like a log but is a dir
if err := os.Mkdir(subdir, 0o755); err != nil {
t.Fatal(err)
}
setModTime(t, subdir, 60*24*time.Hour)
if err := CleanupLogs(dir, 100, 30); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(subdir); err != nil {
t.Errorf("subdirectory should not be deleted: %v", err)
}
}
// TestCleanupLogsZeroLimitsDisableBothPolicies confirms that maxFiles=0 disables
// count-based cleanup and maxAgeDays=0 disables age-based cleanup independently.
func TestCleanupLogsZeroLimitsDisableBothPolicies(t *testing.T) {
dir := t.TempDir()
for i := 0; i < 5; i++ {
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
setModTime(t, path, 60*24*time.Hour) // very old
}
if err := CleanupLogs(dir, 0, 0); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 5 {
t.Errorf("expected all 5 files kept with both limits disabled, got %d", len(entries))
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
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 domain.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, ",")
}
func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) }
+2
View File
@@ -64,3 +64,5 @@ func LogArguments(arguments string) string {
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func logArguments(arguments string) string { return LogArguments(arguments) }
+8 -35
View File
@@ -6,7 +6,6 @@ import (
"strings"
"syscall"
"unicode"
"unicode/utf8"
)
func shellCommand(ctx context.Context, command string) *exec.Cmd {
@@ -33,45 +32,19 @@ func quoteLeadingWindowsProgramPath(command string) string {
}
lower := strings.ToLower(trimmed)
pathEnd := -1
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
end := earliestBoundedExtensionEnd(lower, extension)
if end >= 0 && (pathEnd < 0 || end < pathEnd) {
pathEnd = end
}
}
if pathEnd < 0 {
return command
}
programPath := trimmed[:pathEnd]
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
return command
}
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
}
// earliestBoundedExtensionEnd returns the offset just past the first
// occurrence of extension in s that ends at a token boundary (end of string
// or whitespace), or -1 if none does. Scanning left to right and rejecting
// unbounded matches keeps a trailing "...\App.exe" inside an argument, such
// as "run.bat C:\tool.exe", from being mistaken for the program path.
func earliestBoundedExtensionEnd(s, extension string) int {
offset := 0
for {
index := strings.Index(s[offset:], extension)
index := strings.Index(lower, extension)
if index < 0 {
return -1
continue
}
end := offset + index + len(extension)
if end == len(s) {
return end
pathEnd := index + len(extension)
programPath := trimmed[:pathEnd]
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
return command
}
r, _ := utf8.DecodeRuneInString(s[end:])
if unicode.IsSpace(r) {
return end
}
offset += index + 1
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
}
return command
}
func startsWithWindowsRootedPath(command string) bool {
+9 -68
View File
@@ -1,7 +1,6 @@
package runner
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -12,82 +11,24 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) (string, error) {
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return "", errors.New("logs directory is empty")
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return "", fmt.Errorf("create logs directory: %w", err)
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 := uniqueLogPath(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, LogArguments(job.Arguments), job.StartOnly, output)
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write log file: %w", err)
}
return path, nil
}
// writeFileAtomic writes data to a temp file in dir, then renames it over
// path. Rename is atomic within a volume on both supported platforms, so a
// crash or a killed process mid-write can never leave path holding a
// truncated log file the way a direct os.WriteFile could.
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpPath := tmp.Name()
success := false
defer func() {
if !success {
os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, perm); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
success = true
return nil
}
// uniqueLogPath returns a path for fileName in dir, appending a disambiguating
// "-2", "-3", … suffix before the extension if the plain name is already
// taken. Two runs of the same job in the same second — a fast manual re-run,
// or a sub-second queue drain — would otherwise share one timestamp and the
// second write would silently overwrite the first.
func uniqueLogPath(dir, fileName string) string {
path := filepath.Join(dir, fileName)
if _, err := os.Stat(path); err != nil {
return path
}
ext := filepath.Ext(fileName)
base := strings.TrimSuffix(fileName, ext)
for n := 2; ; n++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, n, ext))
if _, err := os.Stat(candidate); err != nil {
return candidate
}
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 {
-36
View File
@@ -1,36 +0,0 @@
package runner
import (
"os"
"path/filepath"
"testing"
)
// TestUniqueLogPathAvoidsCollision pins the fix for two runs of the same job
// landing on the same second: without disambiguation the second write would
// silently overwrite the first.
func TestUniqueLogPathAvoidsCollision(t *testing.T) {
dir := t.TempDir()
const name = "20260101-120000_job.log"
first := uniqueLogPath(dir, name)
if first != filepath.Join(dir, name) {
t.Fatalf("first call: got %q, want the plain name", first)
}
if err := os.WriteFile(first, []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
second := uniqueLogPath(dir, name)
if second == first {
t.Fatalf("second call returned the same path as an existing file: %q", second)
}
if err := os.WriteFile(second, []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
third := uniqueLogPath(dir, name)
if third == first || third == second {
t.Fatalf("third call collided with an existing file: %q (existing: %q, %q)", third, first, second)
}
}
+31 -47
View File
@@ -12,42 +12,24 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error) {
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
started := time.Now()
// Commands can hang forever if a script waits for input or a child process
// stalls. The effective timeout is resolved by the caller (per-job value or
// the global default), keeping the runner ignorant of the global config. A
// non-positive timeout means "no timeout": context.WithTimeout(ctx, 0) would
// expire immediately, so fall back to a plain cancelable context that only
// ever ends via ctx (e.g. app shutdown).
var runCtx context.Context
var cancel context.CancelFunc
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
} else {
runCtx, cancel = context.WithCancel(ctx)
}
// 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 output string
var state string
var detail string
var durationMS int64
if job.StartOnly {
// A StartOnly process is deliberately never waited for, so it must not be
// tied to any cancelable context: exec.CommandContext leaves a watcher
// goroutine alive until Wait returns or the context is done, and since
// StartOnly never calls Wait that goroutine would live for the rest of the
// process — one per run — and then try to kill a process whose handle
// startJobOnly has already released. context.Background() has a nil Done
// channel, so os/exec starts no watcher at all and the started process is
// left to outlive GoSentry, which is the point of the option.
invocation := jobInvocation(context.Background(), *job)
// StartOnly jobs don't wait for process exit, so the duration measures
// launch latency (time to spawn the process) rather than run time.
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
var stdoutBuf strings.Builder
var stderrBuf strings.Builder
@@ -62,47 +44,44 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
durationMS = duration.Milliseconds()
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, timeout)
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
timestamp := now.Format("2006-01-02 15:04:05")
logFile, logErr := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
// The runner is now pure with respect to the job: it returns a RunRecord and
// lets the caller fold that record into the job's JobRuntime. Run state no
// longer lives on Job, so there is nothing on the job to mutate here.
return domain.RunRecord{
Time: timestamp,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
DurationMS: durationMS,
}, logErr
Time: timestamp,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
}
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string, int64) {
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
winproc.ConfigureHiddenWindow(command)
}
err := command.Start()
duration := time.Since(started).Round(time.Millisecond)
durationMS := duration.Milliseconds()
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0), durationMS
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), durationMS
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), durationMS
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job domain.Job, pid int) string {
@@ -116,24 +95,29 @@ func startOnlyOutput(job domain.Job, pid int) string {
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(LogArguments(job.Arguments))
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, timeout time.Duration) (string, string) {
func runStateDetail(err error, runErr error, duration time.Duration, job domain.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", timeout)
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) {
return "Failed", fmt.Sprintf("Failed with exit code %d", exitError.ExitCode())
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)
}
+106 -168
View File
@@ -10,6 +10,7 @@ import (
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
func echoCommand(message string) string {
@@ -22,15 +23,13 @@ func echoCommand(message string) string {
func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir()
job := domain.Job{
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
SuccessExitCodes: "0,1",
}
record, err := RunJob(context.Background(), &job, "Schedule", logsDir, 30*time.Second)
if err != nil {
t.Fatal(err)
}
record := RunJob(context.Background(), &job, "Schedule", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file to be written")
}
@@ -49,6 +48,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
"detail: ",
"command: " + job.Command,
"arguments: <empty>",
"success_exit_codes: 0,1",
"start_only: false",
"stdout:",
"stderr:",
@@ -77,10 +77,7 @@ func TestRunJobRecordFields(t *testing.T) {
Command: echoCommand("record field check"),
}
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir(), 30*time.Second)
if err != nil {
t.Fatal(err)
}
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
if record.JobID != job.ID {
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
@@ -135,8 +132,8 @@ func TestLogArguments(t *testing.T) {
{"--flag\n--value", "--flag\n--value"},
}
for _, tc := range cases {
if got := LogArguments(tc.input); got != tc.want {
t.Errorf("LogArguments(%q) = %q, want %q", tc.input, got, tc.want)
if got := logArguments(tc.input); got != tc.want {
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
@@ -164,10 +161,7 @@ func TestRunJobWritesLogFile(t *testing.T) {
Command: echoCommand("hello from test"),
}
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil {
t.Fatal(err)
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file path")
}
@@ -202,10 +196,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
}
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil {
t.Fatal(err)
}
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)
}
@@ -234,10 +225,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
Command: scriptPath,
}
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil {
t.Fatal(err)
}
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)
}
@@ -259,10 +247,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
Arguments: "/C\necho separate arguments ok",
}
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil {
t.Fatal(err)
}
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)
}
@@ -271,28 +256,50 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
}
}
func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
command := `sh -c 'exit 1'`
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := domain.Job{
ID: 47,
Name: "Non-zero Exit Code",
Command: command,
ID: 46,
Name: "Accepted Exit Code",
Command: command,
SuccessExitCodes: "0,1",
}
if runtime.GOOS == "windows" {
job.Arguments = "/C\nexit /b 1"
}
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil {
t.Fatal(err)
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 := domain.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 non-zero exit code to fail, got state %q detail %q", record.State, record.Detail)
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") {
if !strings.Contains(record.Detail, "Exit code 1") {
t.Fatalf("expected exit code detail, got %q", record.Detail)
}
}
@@ -312,10 +319,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
StartOnly: true,
}
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil {
t.Fatal(err)
}
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)
}
@@ -336,10 +340,7 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
StartOnly: true,
}
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil {
t.Fatal(err)
}
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)
}
@@ -348,136 +349,73 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
}
}
func TestRunJobTimesOut(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
// timeout waits ~5s; ping to localhost is a portable stall on hosts where
// timeout refuses to run without an interactive console.
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
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)
}
job := domain.Job{
ID: 50,
Name: "Timeout Test",
Command: command,
Arguments: arguments,
}
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if record.State != "Failed" {
t.Fatalf("expected timed-out job to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "Timed out after 100ms") {
t.Fatalf("expected timeout detail with the effective timeout, got %q", record.Detail)
}
}
func TestRunJobZeroTimeoutMeansNoTimeout(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 0.2"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nping -n 2 127.0.0.1 >NUL"
}
job := domain.Job{
ID: 52,
Name: "No Timeout Test",
Command: command,
Arguments: arguments,
}
// A non-positive timeout must not expire immediately (context.WithTimeout
// with a zero duration would); the job must run to completion.
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 0)
if err != nil {
t.Fatal(err)
}
if record.State != "OK" {
t.Fatalf("expected job with no timeout to complete OK, got state %q detail %q", record.State, record.Detail)
}
}
// A StartOnly run must not leave a watcher goroutine behind. exec.CommandContext
// keeps one alive until Wait returns or the context is done, and StartOnly never
// waits, so binding it to the caller's cancelable context would leak one
// goroutine per run for the lifetime of the app — and then, on shutdown, kill a
// process whose handle startJobOnly has already released.
func TestRunJobStartOnlyLeavesNoContextWatcher(t *testing.T) {
command := "sh"
arguments := "-c\nexit 0"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 0"
}
job := domain.Job{
ID: 53,
Name: "Start Only Goroutines",
Command: command,
Arguments: arguments,
StartOnly: true,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const runs = 5
before := settledGoroutines()
for i := 0; i < runs; i++ {
if _, err := RunJob(ctx, &job, "Manual", t.TempDir(), 30*time.Second); err != nil {
t.Fatal(err)
for index := range want {
if got[index] != want[index] {
t.Fatalf("expected %v, got %v", want, got)
}
}
// Counted before cancel on purpose: a watcher would still be parked on
// ctx.Done() at this point, and cancelling first would release it.
if leaked := settledGoroutines() - before; leaked > 1 {
t.Errorf("%d goroutines left after %d StartOnly runs, want none tied to the run context", leaked, runs)
}
func TestDirectCommandDoesNotHideWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), domain.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")
}
}
// settledGoroutines returns the goroutine count once it has stopped falling, so
// a goroutine that is still on its way out is not mistaken for a leak.
func settledGoroutines() int {
lowest := runtime.NumGoroutine()
for stable, i := 0, 0; stable < 3 && i < 100; i++ {
time.Sleep(10 * time.Millisecond)
if count := runtime.NumGoroutine(); count < lowest {
lowest, stable = count, 0
continue
}
stable++
}
return lowest
}
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
}
job := domain.Job{
ID: 51,
Name: "Start Only Timeout",
Command: command,
Arguments: arguments,
StartOnly: true,
func TestShellCommandHidesWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
// A tiny run timeout must not affect StartOnly jobs: they never wait on the
// timed run context, so the launch succeeds regardless.
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), time.Millisecond)
if err != nil {
t.Fatal(err)
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
if record.State != "OK" {
t.Fatalf("expected start-only job to be OK despite tiny timeout, 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)
winproc.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"`)
winproc.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)
}
}
-94
View File
@@ -1,94 +0,0 @@
//go:build windows
package runner
import (
"context"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
func TestDirectCommandDoesNotHideWindow(t *testing.T) {
invocation := jobInvocation(context.Background(), domain.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) {
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
winproc.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) {
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
winproc.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) {
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)
}
}
// TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension pins the
// fix for the quoting bug found in the whole-project review: the program
// path must end at the *earliest* extension match that sits at a token
// boundary, not the first extension in the .exe/.cmd/.bat/.com list order,
// and not a substring match inside another word.
func TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension(t *testing.T) {
cases := []struct {
name string
command string
want string
}{
{
name: "bat with unquoted argument",
command: `C:\My Tools\run.bat D:\in.txt`,
want: `"C:\My Tools\run.bat" D:\in.txt`,
},
{
name: "bat program with exe argument",
command: `C:\My Tools\run.bat C:\Windows\System32\notepad.exe`,
want: `"C:\My Tools\run.bat" C:\Windows\System32\notepad.exe`,
},
{
name: "cmd program with exe argument",
command: `C:\Program Files\App\deploy.cmd D:\stage\setup.exe`,
want: `"C:\Program Files\App\deploy.cmd" D:\stage\setup.exe`,
},
{
name: "exe substring inside directory name",
command: `C:\dir.exexample\My Tool\run.bat`,
want: `"C:\dir.exexample\My Tool\run.bat"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := quoteLeadingWindowsProgramPath(tc.command); got != tc.want {
t.Fatalf("quoteLeadingWindowsProgramPath(%q) = %q, want %q", tc.command, got, tc.want)
}
})
}
}
-173
View File
@@ -1,173 +0,0 @@
package runner
import (
"bufio"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// SeededStats are the aggregate execution-time statistics reconstructed from a
// job's existing log files at startup. The fields mirror the run-time counters
// on domain.JobRuntime so the caller can fold them in directly.
type SeededStats struct {
RunCount int
FailCount int
LastDurationMS int64
AvgDurationMS int64
MaxDurationMS int64
TimedRunCount int
// DurationSumMS is the running total AvgDurationMS was computed from, folded
// into JobRuntime.DurationSumMS so app.updateStats continues the same exact
// sum instead of restarting from a value it would have to reverse-multiply.
DurationSumMS int64
}
// SeedStats scans logsDir once and reconstructs per-job execution-time
// statistics from the log files written by previous runs, keyed by Job.ID.
//
// Log files are matched primarily by the job_id header line writeRunLog writes.
// When that header is absent (legacy logs), files fall back to the sanitized
// job-name suffix in the filename. For each job only the newest maxFiles
// matching logs are parsed, mirroring the retention policy that CleanupLogs
// enforces; a maxFiles of zero or less means "no bound". The duration and state
// are read from each log's header. Logs written before duration tracking existed
// carry no duration line: those are tolerated — they still count toward RunCount
// and FailCount but are left out of the duration aggregates (last/avg/max) so a
// missing duration cannot masquerade as a zero-millisecond run.
//
// A missing or unreadable logs directory yields an empty map rather than an
// error: seeding is best-effort and must never block startup.
func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededStats {
result := make(map[int]SeededStats, len(jobs))
entries, err := os.ReadDir(logsDir)
if err != nil {
return result
}
byID := make(map[int][]logSummary)
byName := make(map[string][]logSummary)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".log") {
continue
}
summary := readLogSummary(filepath.Join(logsDir, name))
summary.name = name
if summary.hasJobID {
byID[summary.jobID] = append(byID[summary.jobID], summary)
continue
}
base := name[:len(name)-len(".log")]
idx := strings.Index(base, "_")
if idx < 0 {
continue
}
byName[base[idx+1:]] = append(byName[base[idx+1:]], summary)
}
for _, job := range jobs {
files := byID[job.ID]
if len(files) == 0 {
files = byName[sanitizeFileName(job.Name)]
}
if len(files) == 0 {
continue
}
// The timestamp prefix sorts chronologically, so a lexical sort by file
// name puts the oldest first; keep the newest maxFiles to honor the
// retention bound.
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
if maxFiles > 0 && len(files) > maxFiles {
files = files[len(files)-maxFiles:]
}
result[job.ID] = aggregateLogStats(files)
}
return result
}
// aggregateLogStats folds the already-read header of each log file (oldest
// first) into one SeededStats. Files lacking a duration line contribute to the
// run/fail counts but not to the duration aggregates.
func aggregateLogStats(files []logSummary) SeededStats {
var stats SeededStats
var durationSum int64
var durationCount int
for _, file := range files {
stats.RunCount++
if file.state == "Failed" {
stats.FailCount++
}
if file.hasDuration {
// Files are oldest first, so the last assignment is the newest run.
stats.LastDurationMS = file.durationMS
if file.durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = file.durationMS
}
durationSum += file.durationMS
durationCount++
}
}
if durationCount > 0 {
stats.TimedRunCount = durationCount
stats.DurationSumMS = durationSum
stats.AvgDurationMS = durationSum / int64(durationCount)
}
return stats
}
// logSummary is everything SeedStats needs from one run log: the file name it
// sorts by, which job wrote it, how the run ended, and how long it took.
type logSummary struct {
name string
jobID int
hasJobID bool
state string
durationMS int64
hasDuration bool
}
// readLogSummary reads the job_id, state, and duration fields from a log file's
// header (the lines before the first blank line) in a single pass, so seeding
// opens each log once rather than once to find its job and again to read its
// result. The has* flags report whether a well-formed line was present,
// distinguishing a legacy log written before the field existed from one that
// genuinely recorded a zero value. An unreadable file yields a zero summary,
// which falls back to matching by the job name in the file name.
func readLogSummary(path string) logSummary {
var summary logSummary
file, err := os.Open(path)
if err != nil {
return summary
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break // end of header
}
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
if id, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil {
summary.jobID = id
summary.hasJobID = true
}
} else if rest, ok := strings.CutPrefix(line, "state: "); ok {
summary.state = strings.TrimSpace(rest)
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
summary.durationMS = value
summary.hasDuration = true
}
}
}
return summary
}
-164
View File
@@ -1,164 +0,0 @@
package runner
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// writeTestLog writes a minimal log file in the format writeRunLog produces.
// Pass durationMS < 0 to omit the duration line (legacy log simulation).
// When jobID > 0 a job_id header line is included.
func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, jobID int) {
t.Helper()
var content strings.Builder
if jobID > 0 {
content.WriteString("job_id: ")
content.WriteString(strconv.Itoa(jobID))
content.WriteString("\n")
}
if durationMS >= 0 {
content.WriteString("state: " + state + "\nduration: " + strconv.FormatInt(durationMS, 10) + "\n\n")
} else {
content.WriteString("state: " + state + "\n\n")
}
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content.String()), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSeedStatsBasic(t *testing.T) {
dir := t.TempDir()
job := domain.Job{ID: 1, Name: "Build"}
name := sanitizeFileName(job.Name)
writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200, job.ID)
writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400, job.ID)
writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600, job.ID)
result := SeedStats(dir, []domain.Job{job}, 0)
s, ok := result[job.ID]
if !ok {
t.Fatal("expected stats for job 1")
}
if s.RunCount != 3 {
t.Errorf("RunCount = %d, want 3", s.RunCount)
}
if s.FailCount != 1 {
t.Errorf("FailCount = %d, want 1", s.FailCount)
}
if s.LastDurationMS != 600 {
t.Errorf("LastDurationMS = %d, want 600", s.LastDurationMS)
}
if s.MaxDurationMS != 600 {
t.Errorf("MaxDurationMS = %d, want 600", s.MaxDurationMS)
}
// avg = (200+400+600)/3 = 400
if s.AvgDurationMS != 400 {
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
}
if s.DurationSumMS != 1200 {
t.Errorf("DurationSumMS = %d, want 1200", s.DurationSumMS)
}
}
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
// line still contributes to RunCount/FailCount but is excluded from duration
// aggregates, so a missing duration cannot masquerade as a 0 ms run.
func TestSeedStatsDurationLessLegacyLog(t *testing.T) {
dir := t.TempDir()
job := domain.Job{ID: 2, Name: "Deploy"}
name := sanitizeFileName(job.Name)
// Legacy log (no duration line).
writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1, job.ID)
// Modern log with duration.
writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300, job.ID)
result := SeedStats(dir, []domain.Job{job}, 0)
s := result[job.ID]
if s.RunCount != 2 {
t.Errorf("RunCount = %d, want 2", s.RunCount)
}
if s.FailCount != 0 {
t.Errorf("FailCount = %d, want 0", s.FailCount)
}
// Only the modern log has a duration — avg/last/max must reflect that single entry.
if s.LastDurationMS != 300 {
t.Errorf("LastDurationMS = %d, want 300", s.LastDurationMS)
}
if s.AvgDurationMS != 300 {
t.Errorf("AvgDurationMS = %d, want 300", s.AvgDurationMS)
}
}
// TestSeedStatsMaxFilesHonoured verifies that only the newest N logs are
// parsed when maxFiles is positive.
func TestSeedStatsMaxFilesHonoured(t *testing.T) {
dir := t.TempDir()
job := domain.Job{ID: 3, Name: "Cleanup"}
name := sanitizeFileName(job.Name)
// Write 3 logs; only the 2 newest should be counted (maxFiles=2).
writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100, job.ID)
writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200, job.ID)
writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300, job.ID)
result := SeedStats(dir, []domain.Job{job}, 2)
s := result[job.ID]
if s.RunCount != 2 {
t.Errorf("RunCount = %d, want 2 (maxFiles=2)", s.RunCount)
}
if s.FailCount != 1 {
t.Errorf("FailCount = %d, want 1", s.FailCount)
}
}
// TestSeedStatsMissingDir yields an empty map and does not panic.
func TestSeedStatsMissingDir(t *testing.T) {
result := SeedStats(filepath.Join(t.TempDir(), "no-such-dir"), []domain.Job{{ID: 1, Name: "J"}}, 0)
if len(result) != 0 {
t.Errorf("expected empty result for missing dir, got %v", result)
}
}
// TestSeedStatsUnknownJobProducesNoEntry verifies that log files not matching
// any known job are silently ignored.
func TestSeedStatsUnknownJobProducesNoEntry(t *testing.T) {
dir := t.TempDir()
writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100, 0)
result := SeedStats(dir, []domain.Job{{ID: 1, Name: "KnownJob"}}, 0)
if _, ok := result[1]; ok {
t.Error("expected no entry for a job with no matching log files")
}
}
// TestSeedStatsMatchesByJobID verifies that logs are associated by job_id even
// when sanitized job names would collide.
func TestSeedStatsMatchesByJobID(t *testing.T) {
dir := t.TempDir()
jobA := domain.Job{ID: 1, Name: "foo@bar"}
jobB := domain.Job{ID: 2, Name: "foo bar"}
colliding := sanitizeFileName(jobA.Name)
if colliding != sanitizeFileName(jobB.Name) {
t.Fatalf("test setup: expected colliding sanitized names, got %q and %q", sanitizeFileName(jobA.Name), sanitizeFileName(jobB.Name))
}
writeTestLog(t, dir, "20260601-100000_"+colliding+".log", "OK", 100, jobA.ID)
writeTestLog(t, dir, "20260601-110000_"+colliding+".log", "Failed", 200, jobB.ID)
result := SeedStats(dir, []domain.Job{jobA, jobB}, 0)
if result[jobA.ID].RunCount != 1 || result[jobA.ID].LastDurationMS != 100 {
t.Errorf("job A stats = %+v, want one OK run at 100 ms", result[jobA.ID])
}
if result[jobB.ID].RunCount != 1 || result[jobB.ID].FailCount != 1 || result[jobB.ID].LastDurationMS != 200 {
t.Errorf("job B stats = %+v, want one Failed run at 200 ms", result[jobB.ID])
}
}
+13 -15
View File
@@ -8,14 +8,15 @@ import (
const (
// The config file stays beside the executable so the portable build behaves
// predictably: moving the program folder moves its settings with it.
ConfigFileName = "gosentry.json"
// Jobs are kept in a separate JSON file because the user can point the
// configuration at any jobs file they like, while application settings
// remain local to the installed/copied program. This is only the default
// name, used before the config is read and when an older config that named
// just a directory is migrated.
JobsFileName = "jobs.json"
ConfigFileName = "gosentry.yaml"
// Older builds were named PySentry. Keep the old config name readable during
// the rename window so portable installations can start once and rewrite the
// settings to gosentry.yaml without manual file copying.
LegacyConfigFileName = "pysentry.yaml"
// Jobs are kept in a separate YAML file because the user can choose a
// different jobs directory, while application settings remain local to the
// installed/copied program.
JobsFileName = "jobs.yaml"
)
// Paths contains both the physical program location and the resolved runtime
@@ -25,13 +26,10 @@ type Paths struct {
ExecutablePath string
AppDir string
ConfigPath string
// JobsDir is the directory containing JobsPath. It is derived from the
// configured jobs file, never configured on its own, and exists so writers
// can create the folder before saving.
JobsDir string
JobsPath string
LogsDir string
DesktopIcon string
JobsDir string
JobsPath string
LogsDir string
DesktopIcon string
}
func ResolvePaths() (Paths, error) {
+85 -220
View File
@@ -1,7 +1,6 @@
package storage
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -9,6 +8,7 @@ import (
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
@@ -16,28 +16,6 @@ type Store struct {
Config domain.Config
}
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
// decisions that must run before app.Open(). On error it returns the built-in
// default.
//
// Despite the name, this can write: loadOrCreateConfig creates gosentry.json
// with defaults on first run, the same as OpenStore does moments later when
// app.Open() parses the now-existing file again. The double parse and the
// write-on-read are both harmless — the second read just sees the file the
// first one created — but worth knowing before adding a third startup path
// that also wants an early look at the config.
func PeekKeepRunningInTray() bool {
paths, err := ResolvePaths()
if err != nil {
return domain.DefaultConfig().KeepRunningInTray
}
config, err := loadOrCreateConfig(paths)
if err != nil {
return domain.DefaultConfig().KeepRunningInTray
}
return config.KeepRunningInTray
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
@@ -63,169 +41,115 @@ func OpenStore() (*Store, []domain.Job, error) {
return nil, nil, err
}
normalizeJobs(jobs)
// Jobs are also rewritten after normalization. That keeps jobs.json compact:
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
// only durable job definitions remain, because runtime fields are tagged
// json:"-" in the model.
// yaml:"-" in the model.
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
// PrepareSaveConfig re-resolves the derived paths from the current config and
// snapshots everything the write needs, returning the write itself as a closure.
// It exists so a caller that guards the Store with its own lock can do the file
// I/O — a marshal, an fsync, and a rename — after releasing that lock: the
// snapshot cannot change under the closure, so running it unlocked is safe.
// Prepared writes must be run in the order they were prepared, or an older
// snapshot can land on top of a newer one.
func (s *Store) PrepareSaveConfig() func() error {
s.applyConfigPaths()
dir := s.Paths.AppDir
path := s.Paths.ConfigPath
config := s.Config
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(path, config)
}
}
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
// copied, so the caller may keep mutating its own slice as soon as this returns.
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
dir := s.Paths.JobsDir
path := s.Paths.JobsPath
snapshot := make([]domain.Job, len(jobs))
copy(snapshot, jobs)
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
}
}
func (s *Store) SaveConfig() error {
return s.PrepareSaveConfig()()
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 []domain.Job) error {
return s.PrepareSaveJobs(jobs)()
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
}
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory.
config := domain.DefaultConfig()
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
return config, writeJSON(paths.ConfigPath, config)
config := domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
}
data, err := os.ReadFile(paths.ConfigPath)
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 domain.Config{}, err
}
// Clearing the default first keeps "the file sets jobs_file" distinguishable
// from "the file omits it", which the jobs_dir migration below depends on.
// The fallbacks restore a value in either case.
config.JobsFile = ""
if err := json.Unmarshal(data, &config); err != nil {
if err := yaml.Unmarshal(data, &config); err != nil {
return domain.Config{}, err
}
// A config written before the setting named a file carries jobs_dir instead
// of jobs_file. Keep its meaning by appending the fixed name that version
// used, then drop the old key so the file is rewritten in the current shape.
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
}
config.JobsDir = ""
if strings.TrimSpace(config.JobsFile) == "" {
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.JobsFile = JobsFileName
config.JobsDir = "."
}
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
// "keep everything" (see runner.CleanupLogs), not a missing value, so
// backfilling it here would make that choice impossible to persist. A config
// written before either field existed already carries 0 from json.Unmarshal
// leaving the DefaultConfig() value in config untouched, so old files still
// pick up 100 / 30 without an explicit backfill.
if config.ExecutionMode == "" {
config.ExecutionMode = domain.ExecutionModeParallel
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.OverlapPolicy == "" {
config.OverlapPolicy = domain.OverlapPolicySkip
}
// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
// value ("no timeout"), not a missing one, so backfilling it here would make
// the setting impossible to persist. Negative values are rejected by
// app.validateConfig before they can be saved.
if config.Theme == "" {
config.Theme = domain.ThemeGoSentry
}
if config.Theme == "default" {
config.Theme = domain.ThemeSystem
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
return config, nil
}
// LoadJobsFile reads and normalizes the job definitions at path. The bool
// reports whether the file was there: a missing file is not an error but the
// answer to "is this file already a jobs file?", which is what the Settings tab
// needs when the user points the application at a different jobs file.
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var file domain.JobsFile
if err := json.Unmarshal(data, &file); err != nil {
return nil, false, err
}
normalizeJobs(file.Jobs)
return file.Jobs, true, nil
}
func loadOrCreateJobs(path string) ([]domain.Job, error) {
jobs, found, err := LoadJobsFile(path)
if _, err := 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, domain.JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if found {
return jobs, nil
var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
// Seed sample jobs so a new user can immediately see scheduled and manual
// execution without inventing a command. The failure sample stays disabled
// so it does not spam notifications; Run now still works for testing.
jobs = defaultJobs()
normalizeJobs(jobs)
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
return file.Jobs, nil
}
func normalizeJobs(jobs []domain.Job) {
next := 1
seen := make(map[int]bool, len(jobs))
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 || seen[job.ID] {
// IDs are assigned only when absent or already claimed by an earlier job
// in this file — a hand-edited jobs.json can carry two entries with the
// same ID, which would otherwise share one runtime, one schedule-cache
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
// because History and future log associations use them to identify jobs.
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
}
seen[job.ID] = true
if job.ID >= next {
next = job.ID + 1
}
@@ -241,92 +165,48 @@ func normalizeJobs(jobs []domain.Job) {
job.Command = echoCommand("GoSentry job ran")
}
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
// Runtime state (last run, next run, status, output, activity) is no longer
// part of Job. It is reconstructed each time the app starts via
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
}
}
// ResolveConfiguredPath turns a file or directory path from the config into the
// absolute path the application will actually use. It is exported so callers
// outside storage — the settings tab, which opens the configured logs folder —
// apply the same rule to a path the user has typed but not yet saved.
func ResolveConfiguredPath(appDir string, path string) string {
if filepath.IsAbs(path) {
// Cleaned so two spellings of the same file (forward vs. backslashes, a
// trailing separator) resolve to the same string. UpdateSettings compares
// this against Paths.JobsPath to decide whether the jobs file is changing,
// so an uncleaned path here could trigger a spurious adoption against the
// file the app is already using.
return filepath.Clean(path)
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, path))
return filepath.Clean(filepath.Join(appDir, dir))
}
func (s *Store) applyConfigPaths() {
// The jobs file is configured as a whole path; its directory is derived so
// SaveJobs can create the folder when the user points at a new location.
s.Paths.JobsPath = ResolveConfiguredPath(s.Paths.AppDir, s.Config.JobsFile)
s.Paths.JobsDir = filepath.Dir(s.Paths.JobsPath)
s.Paths.LogsDir = ResolveConfiguredPath(s.Paths.AppDir, s.Config.LogsDir)
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 writeJSON(path string, value any) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
func writeYAML(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
data, err := yaml.Marshal(value)
if err != nil {
return err
}
// A trailing newline keeps the file friendly to editors and diff tools that
// expect text files to end with one.
data = append(data, '\n')
return writeFileAtomic(dir, path, data, 0o644)
}
// writeFileAtomic writes data to a temp file in dir, syncs it, then renames it
// over path. Rename is atomic within a volume on both supported platforms, so
// a crash, a power loss, or the process being killed mid-write can never leave
// path holding a truncated or empty file the way a direct os.WriteFile could.
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpPath := tmp.Name()
// Any failure past this point must remove the temp file rather than leave
// it behind for the next write to trip over.
success := false
defer func() {
if !success {
os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, perm); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
success = true
return nil
// 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() []domain.Job {
@@ -354,24 +234,9 @@ func defaultJobs() []domain.Job {
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
},
{
ID: 4,
Name: "Failure notification test",
Folder: "Examples",
Schedule: "@every 1m",
Command: failCommand(),
Enabled: false,
},
}
}
func failCommand() string {
if runtime.GOOS == "windows" {
return "exit /b 1"
}
return "exit 1"
}
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
+32 -398
View File
@@ -1,34 +1,33 @@
package storage
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.json")
path := filepath.Join(dir, "jobs.yaml")
original := []domain.Job{
{
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
StartOnly: true,
Enabled: true,
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
SuccessExitCodes: "0,1",
StartOnly: true,
Enabled: true,
},
}
if err := writeJSON(path, domain.JobsFile{Jobs: original}); err != nil {
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
@@ -59,6 +58,9 @@ func TestJobsRoundTrip(t *testing.T) {
if g.Arguments != w.Arguments {
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
}
if g.SuccessExitCodes != w.SuccessExitCodes {
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
}
if g.StartOnly != w.StartOnly {
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
}
@@ -78,15 +80,15 @@ func TestConfigRoundTrip(t *testing.T) {
}
want := domain.Config{
JobsFile: "/custom/jobs/team.json",
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: true,
NotifyOnFailure: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeJSON(paths.ConfigPath, want); err != nil {
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
@@ -95,8 +97,8 @@ func TestConfigRoundTrip(t *testing.T) {
t.Fatal(err)
}
if got.JobsFile != want.JobsFile {
t.Errorf("JobsFile: got %q, want %q", got.JobsFile, want.JobsFile)
if got.JobsDir != want.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
}
if got.LogsDir != want.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
@@ -122,12 +124,12 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", Enabled: true},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// Blank enabled job gets default name, schedule, and command.
// Blank enabled job gets default name, schedule, command, and exit codes.
// normalizeJobs only fills durable configuration now; runtime status is built
// separately by domain.NewRuntime.
if jobs[0].ID != 1 {
@@ -139,350 +141,23 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
if jobs[0].Schedule != "@every 1m" {
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
}
if jobs[0].SuccessExitCodes != "0" {
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
}
// Pre-set fields survive normalization unchanged.
if jobs[2].ID != 5 {
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
}
}
// TestNormalizeJobsReassignsDuplicateIDs pins the fix for a hand-edited
// jobs.json carrying two entries with the same ID: without reassignment both
// would share one JobRuntime, one schedule-cache entry, and one SeedStats
// bucket, so editing or deleting either would silently affect both.
func TestNormalizeJobsReassignsDuplicateIDs(t *testing.T) {
jobs := []domain.Job{
{ID: 5, Name: "First"},
{ID: 5, Name: "Second"},
{ID: 5, Name: "Third"},
}
normalizeJobs(jobs)
seen := make(map[int]bool, len(jobs))
for _, job := range jobs {
if seen[job.ID] {
t.Fatalf("ID %d assigned to more than one job after normalization: %+v", job.ID, jobs)
}
seen[job.ID] = true
}
if jobs[0].ID != 5 {
t.Errorf("first occurrence should keep its ID: got %d, want 5", jobs[0].ID)
}
if jobs[1].ID == 5 || jobs[2].ID == 5 {
t.Errorf("later duplicates should be reassigned away from 5: got %d, %d", jobs[1].ID, jobs[2].ID)
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
// TestResolveConfiguredPathCleansAbsolutePaths pins the fix for two spellings
// of the same absolute path (forward vs. backslashes) resolving to different
// strings: UpdateSettings compares this against Paths.JobsPath as strings to
// decide whether the jobs file is changing, so an uncleaned path here could
// trigger a spurious adoption against the file already in use.
func TestResolveConfiguredPathCleansAbsolutePaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("backslash vs. forward-slash spellings of the same path are a Windows-only ambiguity")
}
got := ResolveConfiguredPath(`C:\app`, "C:/data/jobs.json")
want := ResolveConfiguredPath(`C:\app`, `C:\data\jobs.json`)
if got != want {
t.Errorf("forward-slash and backslash spellings resolved differently: %q vs %q", got, want)
}
}
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsFile != "jobs.json" {
t.Errorf("default JobsFile = %q, want 'jobs.json'", got.JobsFile)
}
if got.LogsDir != "logs" {
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
}
if got.MaxLogFiles != 100 {
t.Errorf("default MaxLogFiles = %d, want 100", got.MaxLogFiles)
}
if got.MaxLogAgeDays != 30 {
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
}
if got.DefaultTimeoutSeconds != 0 {
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
}
if got.Theme != domain.ThemeGoSentry {
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeGoSentry)
}
if got.JobListView != domain.JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
}
// The function must have written the defaults to gosentry.json.
if _, err := os.Stat(paths.ConfigPath); err != nil {
t.Errorf("gosentry.json should have been created: %v", err)
}
}
// TestLoadOrCreateConfigPreservesZeroRetentionLimits verifies that 0 in
// max_log_files / max_log_age_days is read back as 0 ("keep everything"), not
// backfilled to the 100 / 30 defaults, since a config that already has the
// field set is not the "field is missing" case loadOrCreateConfig backfills.
func TestLoadOrCreateConfigPreservesZeroRetentionLimits(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.DefaultConfig()
want.MaxLogFiles = 0
want.MaxLogAgeDays = 0
if err := writeJSON(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.MaxLogFiles != 0 {
t.Errorf("MaxLogFiles: got %d, want 0 (unlimited)", got.MaxLogFiles)
}
if got.MaxLogAgeDays != 0 {
t.Errorf("MaxLogAgeDays: got %d, want 0 (unlimited)", got.MaxLogAgeDays)
}
}
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
// jobs.json is created with the sample jobs from defaultJobs, so a new user
// sees scheduled and manual execution without inventing a command.
func TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.json")
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
want := defaultJobs()
if len(got) != len(want) {
t.Fatalf("got %d jobs, want %d", len(got), len(want))
}
for i := range want {
if got[i].Name != want[i].Name || got[i].Schedule != want[i].Schedule || got[i].Command != want[i].Command || got[i].Enabled != want[i].Enabled {
t.Errorf("job %d = %+v, want %+v", i, got[i], want[i])
}
}
// The function must have written the seeded jobs to jobs.json.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("jobs.json should have been created: %v", err)
}
var file domain.JobsFile
if err := json.Unmarshal(data, &file); err != nil {
t.Fatal(err)
}
if len(file.Jobs) != len(want) {
t.Errorf("jobs.json has %d jobs, want %d", len(file.Jobs), len(want))
}
}
// TestLoadOrCreateConfigMigratesLegacyThemeDefault covers a gosentry.json that
// still stores the retired "default" theme value: load normalizes it to system.
func TestLoadOrCreateConfigMigratesLegacyThemeDefault(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
legacy := map[string]any{
"jobs_file": "jobs.json",
"logs_dir": "logs",
"max_log_files": 100,
"max_log_age_days": 30,
"theme": "default",
}
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.Theme != domain.ThemeSystem {
t.Errorf("migrated Theme = %q, want %q", got.Theme, domain.ThemeSystem)
}
}
// TestLoadOrCreateConfigKeepsZeroTimeoutOnReload guards the "0 = no timeout"
// setting against being normalized away when an existing gosentry.json is read
// back. Loading must not treat 0 as a missing value.
func TestLoadOrCreateConfigKeepsZeroTimeoutOnReload(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
// First call writes the defaults (DefaultTimeoutSeconds = 0) to disk.
if _, err := loadOrCreateConfig(paths); err != nil {
t.Fatal(err)
}
// Second call takes the "file exists" branch, where normalization runs.
reloaded, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if reloaded.DefaultTimeoutSeconds != 0 {
t.Errorf("reloaded DefaultTimeoutSeconds = %d, want 0 (no timeout)", reloaded.DefaultTimeoutSeconds)
}
}
// TestLoadOrCreateConfigMigratesJobsDir covers a gosentry.json written before
// the setting named a file: the old jobs_dir keeps pointing at the same jobs
// file, and the retired key is dropped so it is not written back.
func TestLoadOrCreateConfigMigratesJobsDir(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
legacy := map[string]any{
"jobs_dir": filepath.Join(dir, "shared"),
"logs_dir": "logs",
"max_log_files": 100,
"max_log_age_days": 30,
}
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dir, "shared", JobsFileName)
if got.JobsFile != want {
t.Errorf("migrated JobsFile: got %q, want %q", got.JobsFile, want)
}
if got.JobsDir != "" {
t.Errorf("legacy JobsDir should be cleared, got %q", got.JobsDir)
}
// The migrated config must not carry the retired key once it is saved.
store := &Store{Paths: paths, Config: got}
if err := store.SaveConfig(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(paths.ConfigPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "jobs_dir") {
t.Errorf("saved config should not contain jobs_dir:\n%s", data)
}
}
// TestLoadJobsFileReportsMissingWithoutCreating covers the loader the Settings
// tab uses to decide between adopting a jobs file and writing the current jobs
// to it: a missing file is reported as "not found" rather than an error, and —
// unlike the startup path — is not seeded with sample jobs.
func TestLoadJobsFileReportsMissingWithoutCreating(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "nothing-here.json")
jobs, found, err := LoadJobsFile(missing)
if err != nil {
t.Fatalf("missing file should not be an error: %v", err)
}
if found || jobs != nil {
t.Errorf("missing file: got found=%v jobs=%+v, want false/nil", found, jobs)
}
if _, err := os.Stat(missing); !os.IsNotExist(err) {
t.Error("LoadJobsFile must not create the file it was asked about")
}
// An existing file comes back normalized, so a hand-written jobs file gains
// its IDs and defaults before the application adopts it.
path := filepath.Join(dir, "hand-written.json")
if err := writeJSON(path, domain.JobsFile{Jobs: []domain.Job{{Name: "No ID"}}}); err != nil {
t.Fatal(err)
}
jobs, found, err = LoadJobsFile(path)
if err != nil {
t.Fatal(err)
}
if !found || len(jobs) != 1 {
t.Fatalf("existing file: got found=%v jobs=%+v, want true and one job", found, jobs)
}
if jobs[0].ID != 1 || jobs[0].Schedule == "" || jobs[0].Command == "" {
t.Errorf("loaded job should be normalized, got %+v", jobs[0])
}
}
// TestApplyConfigPathsDerivesJobsDir checks that the jobs file drives both
// resolved paths: relative values resolve against the program folder, and the
// containing directory comes from the file name the user chose.
func TestApplyConfigPathsDerivesJobsDir(t *testing.T) {
dir := t.TempDir()
store := &Store{
Paths: Paths{AppDir: dir},
Config: domain.Config{JobsFile: filepath.Join("shared", "team.json"), LogsDir: "logs"},
}
store.applyConfigPaths()
if want := filepath.Join(dir, "shared", "team.json"); store.Paths.JobsPath != want {
t.Errorf("JobsPath: got %q, want %q", store.Paths.JobsPath, want)
}
if want := filepath.Join(dir, "shared"); store.Paths.JobsDir != want {
t.Errorf("JobsDir: got %q, want %q", store.Paths.JobsDir, want)
}
}
// TestJobTimeoutRoundTripsThreeStates pins the on-disk encoding that keeps
// "inherit" and "no timeout" distinguishable: nil is omitted entirely, while an
// explicit 0 is written and read back as a set value.
func TestJobTimeoutRoundTripsThreeStates(t *testing.T) {
jobs := []domain.Job{
{ID: 1, Name: "Inherit", TimeoutSeconds: nil},
{ID: 2, Name: "No timeout", TimeoutSeconds: domain.TimeoutSecondsPtr(0)},
{ID: 3, Name: "Own", TimeoutSeconds: domain.TimeoutSecondsPtr(45)},
}
data, err := json.Marshal(domain.JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
if want := `"timeout_seconds":0`; !strings.Contains(string(data), want) {
t.Fatalf("explicit zero timeout should be written as %s:\n%s", want, data)
}
var got domain.JobsFile
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if got.Jobs[0].TimeoutSeconds != nil {
t.Errorf("unset timeout should stay nil, got %d", *got.Jobs[0].TimeoutSeconds)
}
if got.Jobs[1].TimeoutSeconds == nil || *got.Jobs[1].TimeoutSeconds != 0 {
t.Errorf("explicit zero timeout should survive the round trip, got %v", got.Jobs[1].TimeoutSeconds)
}
if got.Jobs[2].TimeoutSeconds == nil || *got.Jobs[2].TimeoutSeconds != 45 {
t.Errorf("per-job timeout should survive the round trip, got %v", got.Jobs[2].TimeoutSeconds)
}
}
func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
// Job carries only durable configuration; runtime state lives in
// domain.JobRuntime and is never marshalled. This guards against a future
// runtime field accidentally being added back onto Job with a json tag.
// runtime field accidentally being added back onto Job with a yaml tag.
jobs := []domain.Job{
{
ID: 1,
@@ -493,55 +168,14 @@ func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
},
}
data, err := json.Marshal(domain.JobsFile{Jobs: jobs})
data, err := yaml.Marshal(domain.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 json should not contain %q:\n%s", unwanted, text)
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
// TestWriteJSONReplacesFileAtomically pins the durability fix: writeJSON must
// never truncate the destination in place. It writes through a temp file and
// renames over the target, so a reader can never observe a partially written
// file, and an existing file survives untouched if the marshal fails first.
func TestWriteJSONReplacesFileAtomically(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gosentry.json")
original := domain.DefaultConfig()
original.LogsDir = "logs-original"
if err := writeJSON(path, original); err != nil {
t.Fatal(err)
}
updated := domain.DefaultConfig()
updated.LogsDir = "logs-updated"
if err := writeJSON(path, updated); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var got domain.Config
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if got.LogsDir != "logs-updated" {
t.Fatalf("LogsDir = %q, want %q", got.LogsDir, "logs-updated")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected only the final file in %s, got %v", dir, entries)
}
}
+50 -254
View File
@@ -5,9 +5,10 @@ import (
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -24,295 +25,90 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
}
}
// textWidth measures how wide s renders at the theme's current body text size.
func textWidth(s string) float32 {
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
}
// cellPadding is the horizontal space a table cell reserves around its text.
// It replaces a hand-tuned pixel constant with the theme's own inner padding
// doubled (one side each), so it follows text size and DPI.
func cellPadding() float32 { return 2 * theme.InnerPadding() }
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
// column: the minimum keeps a column readable when its values are short or
// absent, the maximum stops one very long value from dominating the table
// (the table still scrolls horizontally past it). Expressed as measured text
// rather than raw pixels so both follow the theme instead of drifting from it.
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
// textColumnWidth measures the widest of samples so a table column can be
// sized to fit its content, clamped to [min, max]. Fyne tables do not
// auto-size columns, so without this a fixed width clips values like
// "20260601-100000_SomeJobName.log" in the Log column.
func textColumnWidth(samples []string, min, max float32) float32 {
width := min
for _, text := range samples {
if text == "" {
continue
}
if w := textWidth(text) + cellPadding(); w > width {
width = w
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
var events []event
for _, current := range jobs {
// At startup this is usually empty because jobs.yaml does not persist
// runtime logs. The function still centralizes the merge for future
// history loading from log metadata.
if rt := runtimes[current.ID]; rt != nil {
events = append(events, rt.Logs...)
}
}
if width > max {
width = max
}
return width
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
return events
}
// historyTriggerSamples is the closed set of Trigger values History ever
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
// trigger here too if one is introduced there, or the column may clip it.
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
// historyStateSamples is the closed set of State values History ever shows:
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
// Add a new state here too if one is introduced in either place.
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
// historyTimeSample is the rendered form of the timestamp layout every event
// uses (see newEvent), so the Time column needs no content scan: its width is
// fixed by the format string.
const historyTimeSample = "2026-01-02 15:04:05"
// historyColumnWidths computes every column's width from the current sorted
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
// Detail and Log are free text, so their width tracks the values actually
// present, bounded the same way the Log column always was.
func historyColumnWidths(rows []event) [6]float32 {
var content [3][]string
for i := range content {
content[i] = make([]string, 0, len(rows))
}
for _, current := range rows {
for i, value := range historyContentValues(current) {
content[i] = append(content[i], value)
}
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
widths := [6]float32{
0: textWidth(historyTimeSample) + cellPadding(),
1: textColumnWidth(historyTriggerSamples, min, max),
3: textColumnWidth(historyStateSamples, min, max),
}
for i, col := range historyContentCols {
widths[col] = textColumnWidth(content[i], min, max)
}
return widths
}
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
// job's own activity list. History is never persisted and every record carries
// the run's full captured output, so an app left running in the tray — the mode
// GoSentry is designed for — would otherwise hold every record of every run
// forever, and pay a full resort plus a full column-width rescan on each new
// one. One job on @every 10s produces ~8 600 records a day.
const maxHistoryRows = 1000
// historyLog is the session History: the capped record list plus the column
// widths measured from it. It exists so the widths can be folded in one record
// at a time instead of being recomputed from every row on every event, which
// is what made the per-event cost grow with the number of rows.
type historyLog struct {
records []event
widths [6]float32
// textSize and padding are the theme metrics widths were last measured at.
// A theme change invalidates every measurement, so it forces a full rescan
// rather than folding new records into stale numbers.
textSize float32
padding float32
}
func newHistoryLog(records []event) *historyLog {
h := &historyLog{records: trimHistory(records)}
h.rescan()
return h
}
// trimHistory drops the oldest records past the cap. The tail of the backing
// array is zeroed because a dropped record holds the run's whole output, which
// would otherwise stay reachable until the slice happens to be reallocated.
func trimHistory(records []event) []event {
if len(records) <= maxHistoryRows {
return records
}
kept := copy(records, records[len(records)-maxHistoryRows:])
for i := kept; i < len(records); i++ {
records[i] = event{}
}
return records[:kept]
}
// add appends one record and widens any content-measured column the record
// does not fit. Widths only ever grow within a theme: a column is never
// narrowed when a record ages out, because the rows still on screen were laid
// out against the wider value.
func (h *historyLog) add(record event) {
h.records = trimHistory(append(h.records, record))
if h.stale() {
h.rescan()
return
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
for i, value := range historyContentValues(record) {
if width := textColumnWidth([]string{value}, min, max); width > h.widths[historyContentCols[i]] {
h.widths[historyContentCols[i]] = width
}
}
}
// columnWidths returns the widths to apply to the table, rescanning every
// record only when the theme's text metrics have changed since the last scan.
func (h *historyLog) columnWidths() [6]float32 {
if h.stale() {
h.rescan()
}
return h.widths
}
func (h *historyLog) stale() bool {
return theme.TextSize() != h.textSize || cellPadding() != h.padding
}
func (h *historyLog) rescan() {
h.textSize, h.padding = theme.TextSize(), cellPadding()
h.widths = historyColumnWidths(h.records)
}
// historyContentCols are the columns whose width follows the values actually
// present, in the order historyContentValues returns them. Both the
// incremental fold in add and the full scan in historyColumnWidths go through
// this pair, so they cannot disagree about which columns follow content.
var historyContentCols = [3]int{2, 4, 5}
func historyContentValues(record event) [3]string {
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
}
// historyHeader is a bold tappable label used in the History table header row.
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
// toggle is wired through the Tappable interface instead.
type historyHeader struct {
widget.BaseWidget
label *widget.Label
OnTapped func()
}
func newHistoryHeader() *historyHeader {
h := &historyHeader{label: widget.NewLabel("")}
h.label.TextStyle = fyne.TextStyle{Bold: true}
h.label.Truncation = fyne.TextTruncateClip
h.ExtendBaseWidget(h)
return h
}
func (h *historyHeader) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(h.label)
}
func (h *historyHeader) Tapped(*fyne.PointEvent) {
if h.OnTapped != nil {
h.OnTapped()
}
}
func (h *historyHeader) SetText(text string) {
h.label.SetText(text)
}
// historyHeaders are the History table's column captions, in column order. The
// Time caption is built per update because it carries the sort direction arrow.
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
func newHistoryView(log *historyLog) (*fyne.Container, func()) {
func newHistoryView(events *[]event) *fyne.Container {
descending := false
headerText := func(id widget.TableCellID) string {
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
if id.Row < 0 && id.Col == 0 {
if descending {
return "Time "
return "Time desc"
}
return "Time "
return "Time asc"
}
if id.Row < 0 && id.Col >= 0 && id.Col < len(historyHeaders) {
return historyHeaders[id.Col]
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
return headers[id.Col]
}
return ""
}
// rows is the sorted snapshot every callback below reads — both the length
// callback and the cells, which must agree on the same slice. A full redraw
// issues one update call per visible cell, so sorting inside the cell
// callback re-sorted the whole event list a hundred times per Refresh.
// resort() is therefore the only place the order changes, and it runs once
// per redraw: at build time, on a sort toggle, and from refresh().
var rows []event
resort := func() {
rows = append(rows[:0], log.records...)
sort.SliceStable(rows, func(left int, right int) bool {
sortedEvents := func() []event {
result := append([]event(nil), (*events)...)
sort.SliceStable(result, func(left int, right int) bool {
if descending {
return rows[left].Time > rows[right].Time
return result[left].Time > result[right].Time
}
return rows[left].Time < rows[right].Time
return result[left].Time < result[right].Time
})
return result
}
resort()
table := widget.NewTable(
func() (int, int) {
return len(rows), len(historyHeaders)
return len(*events), 6
},
func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Truncation = fyne.TextTruncateClip
label.Wrapping = fyne.TextTruncate
return label
},
func(id widget.TableCellID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(historyCellText(id, rows))
label := item.(*widget.Label)
label.SetText(historyCellText(id, sortedEvents()))
label.TextStyle = fyne.TextStyle{}
label.Refresh()
},
)
table.ShowHeaderRow = true
table.CreateHeader = func() fyne.CanvasObject {
return newHistoryHeader()
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
}
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
h := item.(*historyHeader)
h.SetText(headerText(id))
if id.Row < 0 && id.Col == 0 {
h.OnTapped = func() {
descending = !descending
resort()
table.Refresh()
}
} else {
h.OnTapped = nil
}
h.Refresh()
label := item.(*widget.Label)
label.SetText(headerText(id))
label.TextStyle = fyne.TextStyle{Bold: true}
label.Refresh()
}
table.OnSelected = func(id widget.TableCellID) {
if id.Row < 0 && id.Col == 0 {
descending = !descending
table.Refresh()
}
table.Unselect(id)
}
setColumnWidths := func() {
for col, width := range log.columnWidths() {
table.SetColumnWidth(col, width)
}
}
setColumnWidths()
// refresh re-reads the event list into the sorted snapshot and re-applies
// the column widths before redrawing, so newly recorded events appear in
// the current sort order and longer values widen their column instead of
// being truncated. The widths come from historyLog, which folded each new
// record in as it arrived — this does not rescan every row.
refresh := func() {
resort()
setColumnWidths()
table.Refresh()
}
return container.NewPadded(table), refresh
table.SetColumnWidth(0, 150)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 170)
table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 260)
table.SetColumnWidth(5, 240)
return container.NewPadded(table)
}
func historyCellText(id widget.TableCellID, events []event) string {
-367
View File
@@ -1,367 +0,0 @@
package ui
import (
"strconv"
"strings"
"testing"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
)
func TestLastJobLogsCapsAndCopies(t *testing.T) {
logs := []event{
{Time: "1", JobName: "a"},
{Time: "2", JobName: "b"},
{Time: "3", JobName: "c"},
{Time: "4", JobName: "d"},
}
got := lastJobLogs(logs)
if len(got) != maxJobActivityRows {
t.Fatalf("len = %d, want %d", len(got), maxJobActivityRows)
}
for i, want := range []string{"1", "2", "3"} {
if got[i].Time != want {
t.Errorf("got[%d].Time = %q, want %q", i, got[i].Time, want)
}
}
logs[0].Time = "mutated"
if got[0].Time == "mutated" {
t.Error("lastJobLogs must return a defensive copy")
}
}
func TestLastJobLogsEmpty(t *testing.T) {
if got := lastJobLogs(nil); len(got) != 0 {
t.Errorf("nil input: got %v, want empty", got)
}
}
func TestIndexOfID(t *testing.T) {
jobs := []job{
{ID: 10, Name: "A"},
{ID: 20, Name: "B"},
}
if got := indexOfID(jobs, 20); got != 1 {
t.Errorf("found: got %d, want 1", got)
}
if got := indexOfID(jobs, 99); got != -1 {
t.Errorf("missing: got %d, want -1", got)
}
if got := indexOfID(nil, 1); got != -1 {
t.Errorf("empty slice: got %d, want -1", got)
}
}
func TestHistoryCellText(t *testing.T) {
events := []event{{
Time: "2026-06-01 12:00:00",
Trigger: "",
JobName: "Job",
State: "OK",
Detail: "done",
LogFile: `/logs/20260601-120000_Job.log`,
}}
cases := []struct {
col int
want string
}{
{0, "2026-06-01 12:00:00"},
{1, "Unknown"},
{2, "Job"},
{3, "OK"},
{4, "done"},
{5, "20260601-120000_Job.log"},
}
for _, tc := range cases {
got := historyCellText(widget.TableCellID{Row: 0, Col: tc.col}, events)
if got != tc.want {
t.Errorf("col %d: got %q, want %q", tc.col, got, tc.want)
}
}
if got := historyCellText(widget.TableCellID{Row: -1, Col: 0}, events); got != "" {
t.Errorf("header row: got %q, want empty", got)
}
if got := historyCellText(widget.TableCellID{Row: 99, Col: 0}, events); got != "" {
t.Errorf("out of range row: got %q, want empty", got)
}
}
func TestLogFileName(t *testing.T) {
cases := []struct{ path, want string }{
{"", ""},
{" ", ""},
{`C:\logs\run.log`, "run.log"},
{"/var/logs/2026/job.log", "job.log"},
{"plain.log", "plain.log"},
}
for _, tc := range cases {
if got := logFileName(tc.path); got != tc.want {
t.Errorf("logFileName(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
// TestHistorySortToggleKeepsRowsInSync is the regression guard for F11: the
// table now reads one cached sorted snapshot instead of re-sorting inside every
// cell callback, so the length callback and the cells have to be refilled
// together. If either the sort toggle or refresh stops calling resort(), the
// row count and the cell contents disagree — which no compiler check catches.
func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
events := []event{
{Time: "2026-06-01 10:00:00", JobName: "A"},
{Time: "2026-06-01 11:00:00", JobName: "B"},
{Time: "2026-06-01 12:00:00", JobName: "C"},
}
log := newHistoryLog(events)
content, refresh := newHistoryView(log)
table, ok := content.Objects[0].(*widget.Table)
if !ok {
t.Fatal("history view does not wrap a table")
}
rowCount := func() int {
t.Helper()
rows, cols := table.Length()
if cols != len(historyHeaders) {
t.Errorf("column count = %d, want %d", cols, len(historyHeaders))
}
return rows
}
// Column 2 is the Job name, the field these fixtures vary.
jobAt := func(row int) string {
t.Helper()
cell := table.CreateCell()
table.UpdateCell(widget.TableCellID{Row: row, Col: 2}, cell)
return cell.(*widget.Label).Text
}
// The sort toggle lives on the Time header cell, which is only wired up
// when UpdateHeader runs for it.
header := table.CreateHeader()
table.UpdateHeader(widget.TableCellID{Row: -1, Col: 0}, header)
timeHeader, ok := header.(*historyHeader)
if !ok {
t.Fatal("history table header is not a historyHeader")
}
assertOrder := func(when string, want ...string) {
t.Helper()
if got := rowCount(); got != len(want) {
t.Fatalf("%s: row count = %d, want %d", when, got, len(want))
}
for row, name := range want {
if got := jobAt(row); got != name {
t.Errorf("%s: row %d = %q, want %q", when, row, got, name)
}
}
}
assertOrder("ascending", "A", "B", "C")
test.Tap(timeHeader)
assertOrder("descending", "C", "B", "A")
// A new run arrives while the table is sorted newest-first: it must be
// counted and placed in the order currently on screen, not the build-time one.
log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"})
refresh()
assertOrder("descending after refresh", "D", "C", "B", "A")
test.Tap(timeHeader)
assertOrder("ascending after refresh", "A", "B", "C", "D")
}
// TestHistoryCellTemplateIsPlainText guards the dropped per-cell TextStyle
// assignment: the template must already carry the zero style, since nothing
// resets it any more.
func TestHistoryCellTemplateIsPlainText(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
content, _ := newHistoryView(newHistoryLog(nil))
table := content.Objects[0].(*widget.Table)
label, ok := table.CreateCell().(*widget.Label)
if !ok {
t.Fatal("history cell template is not a label")
}
if label.TextStyle != (fyne.TextStyle{}) {
t.Errorf("cell template TextStyle = %+v, want the zero value", label.TextStyle)
}
}
// TestTextColumnWidthClamps covers the three shapes textColumnWidth has to
// handle: a sample narrower than min, one that lands between the bounds, and
// one wide enough to hit the max cap.
func TestTextColumnWidthClamps(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
min, max := float32(50), float32(120)
if got := textColumnWidth([]string{"x"}, min, max); got != min {
t.Errorf("below-min sample: got %v, want the floor %v", got, min)
}
inRange := textWidth("mid-sized value") + cellPadding()
if inRange <= min || inRange >= max {
t.Skip("fixture sample no longer lands strictly between the bounds under this theme")
}
if got := textColumnWidth([]string{"mid-sized value"}, min, max); got != inRange {
t.Errorf("in-range sample: got %v, want %v", got, inRange)
}
if got := textColumnWidth([]string{strings.Repeat("0", 200)}, min, max); got != max {
t.Errorf("above-max sample: got %v, want the cap %v", got, max)
}
if got := textColumnWidth(nil, min, max); got != min {
t.Errorf("no samples: got %v, want the floor %v", got, min)
}
}
// TestHistoryColumnsFitTheirContent guards F6/F14: every column must be at
// least as wide as its widest known or actually-present value, at the default
// theme and at a scaled one, so nothing that used to be a pixel constant
// clips again.
func TestHistoryColumnsFitTheirContent(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
rows := []event{
{
Time: "2026-06-01 12:00:00",
Trigger: "Schedule",
JobName: "A moderately long job name for width testing",
State: "Jobs loaded",
Detail: "A somewhat longer detail message describing what happened",
LogFile: `/logs/20260601-120000_SomeJobName.log`,
},
}
check := func(when string) {
t.Helper()
widths := historyColumnWidths(rows)
samples := [][]string{
{historyTimeSample},
historyTriggerSamples,
{rows[0].JobName},
historyStateSamples,
{rows[0].Detail},
{logFileName(rows[0].LogFile)},
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
for col, colSamples := range samples {
want := textColumnWidth(colSamples, min, max)
if col == 0 {
want = textWidth(historyTimeSample) + cellPadding()
}
if widths[col] < want {
t.Errorf("%s: column %d width = %v, want at least %v", when, col, widths[col], want)
}
}
}
check("default theme")
testApp.Settings().SetTheme(test.NewTheme())
check("scaled theme")
}
// TestHistoryLogCapsRecords is the regression guard for the unbounded History
// list: an app left in the tray records thousands of runs a day, each carrying
// the run's whole captured output, so the list must drop the oldest instead of
// growing forever.
func TestHistoryLogCapsRecords(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for i := 0; i < maxHistoryRows+25; i++ {
log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)})
}
if len(log.records) != maxHistoryRows {
t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows)
}
if got, want := log.records[0].JobName, "Job 25"; got != want {
t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want)
}
last := log.records[len(log.records)-1].JobName
if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want {
t.Errorf("newest record = %q, want %q", last, want)
}
// A list handed in above the cap is trimmed too, not only one grown into it.
oversized := make([]event, maxHistoryRows+10)
if got := len(newHistoryLog(oversized).records); got != maxHistoryRows {
t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows)
}
}
// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while
// every measured record is still in the list, folding each one in as it
// arrives must give exactly what rescanning every row would, or the cheaper
// path would clip values the old one showed.
func TestHistoryLogWidthsMatchAFullScan(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for _, record := range []event{
{Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`},
{Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`},
{Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`},
} {
log.add(record)
}
if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want {
t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want)
}
}
// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the
// rule: widths only grow. Dropping the record that set a column's width must
// not narrow the column, because the rows on screen were laid out against it.
func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
log.add(event{Time: "1", JobName: "A job name long enough to widen its column"})
widest := log.columnWidths()[2]
for i := 0; i < maxHistoryRows; i++ {
log.add(event{Time: "t", JobName: "x"})
}
if got := log.columnWidths()[2]; got != widest {
t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest)
}
}
// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold
// cannot handle: every stored width was measured at the old text size, so a
// theme change has to fall back to a full rescan.
func TestHistoryLogRescansOnThemeChange(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog([]event{
{Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"},
})
before := log.columnWidths()
testApp.Settings().SetTheme(test.NewTheme())
after := log.columnWidths()
if after == before {
t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics")
}
if want := historyColumnWidths(log.records); after != want {
t.Errorf("widths after theme change = %v, want the rescanned %v", after, want)
}
}
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
ev := newEvent(1, "Job", "OK", "detail")
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
t.Errorf("timestamp %q is not in expected layout: %v", ev.Time, err)
}
if ev.Trigger != "UI" || ev.JobID != 1 || ev.JobName != "Job" {
t.Errorf("unexpected event fields: %+v", ev)
}
}
+12 -49
View File
@@ -2,22 +2,16 @@ package ui
import (
"fmt"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// overlapPolicyInherit is the display label used when a job should inherit the
// global overlap policy. It maps to an empty Job.OverlapPolicy on save.
const overlapPolicyInherit = "(Use global default)"
// showJobDialog opens a create/edit form for a single job. onSave is called
// with the populated job only when the user clicks Save and all fields pass
// validation.
@@ -34,33 +28,16 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
commandEntry := widget.NewEntry()
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
commandEntry.SetText(current.Command)
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFile(w, commandEntry, nil)
})
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
argumentsEntry := widget.NewMultiLineEntry()
// One argument per line is the whole point of the field: each line is passed
// to the process verbatim, so paths with spaces need no quoting.
argumentsEntry.SetPlaceHolder("One argument per line, no quotes:\n--input\n" + `C:\Data\My Files\input.txt`)
argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
argumentsEntry.SetText(current.Arguments)
successExitCodesEntry := widget.NewEntry()
successExitCodesEntry.SetPlaceHolder("0")
successExitCodesEntry.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
startOnly.SetChecked(current.StartOnly)
enabled := widget.NewCheck("Enabled", nil)
enabled.SetChecked(current.Enabled)
overlapSelect := widget.NewSelect(
[]string{overlapPolicyInherit, string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapSelected := overlapPolicyInherit
if current.OverlapPolicy != "" {
overlapSelected = current.OverlapPolicy
}
overlapSelect.SetSelected(overlapSelected)
timeoutEntry := widget.NewEntry()
timeoutEntry.SetPlaceHolder("Empty = global default, 0 = no timeout")
if current.TimeoutSeconds != nil {
timeoutEntry.SetText(strconv.Itoa(*current.TimeoutSeconds))
}
form := dialog.NewForm(
title,
@@ -70,11 +47,10 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
widget.NewFormItem("Name", name),
widget.NewFormItem("Folder", folderEntry),
widget.NewFormItem("Schedule", scheduleEntry),
widget.NewFormItem("Command", commandRow),
widget.NewFormItem("Command", commandEntry),
widget.NewFormItem("Arguments", argumentsEntry),
widget.NewFormItem("Success exit codes", successExitCodesEntry),
widget.NewFormItem("", startOnly),
widget.NewFormItem("Overlap policy", overlapSelect),
widget.NewFormItem("Timeout (s)", timeoutEntry),
widget.NewFormItem("", enabled),
},
func(saved bool) {
@@ -91,30 +67,17 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
return
}
// An empty timeout inherits the global default (nil); an explicit 0
// means "no timeout" and does not inherit; anything else must be a
// positive whole number of seconds.
var timeoutSeconds *int
if trimmed := strings.TrimSpace(timeoutEntry.Text); trimmed != "" {
parsed, err := strconv.Atoi(trimmed)
if err != nil || parsed < 0 {
dialog.ShowError(fmt.Errorf("timeout must be 0 (no timeout) or a positive number of seconds, or empty to use the global default"), w)
return
}
timeoutSeconds = domain.TimeoutSecondsPtr(parsed)
}
current.Name = strings.TrimSpace(name.Text)
current.Folder = strings.TrimSpace(folderEntry.Text)
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
current.Command = strings.TrimSpace(commandEntry.Text)
current.Arguments = strings.TrimSpace(argumentsEntry.Text)
current.SuccessExitCodes = strings.TrimSpace(successExitCodesEntry.Text)
if current.SuccessExitCodes == "" {
current.SuccessExitCodes = "0"
}
current.StartOnly = startOnly.Checked
current.Enabled = enabled.Checked
current.OverlapPolicy = overlapSelect.Selected
if current.OverlapPolicy == overlapPolicyInherit {
current.OverlapPolicy = ""
}
current.TimeoutSeconds = timeoutSeconds
// The dialog only edits durable configuration. Runtime status is
// initialized (new jobs) or updated (edits) by the caller against the
// runtime map, keyed by job ID.
@@ -122,6 +85,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
},
w,
)
form.Resize(fyne.NewSize(640, 500))
form.Resize(fyne.NewSize(640, 460))
form.Show()
}
+373 -164
View File
@@ -1,6 +1,9 @@
package ui
import (
"fmt"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
@@ -14,36 +17,7 @@ import (
const allFolders = "All"
const noFolder = "No folder"
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
// entries. The full per-job history (up to maxJobLogs) remains in the History
// view; this panel is a quick at-a-glance summary anchored below the output.
const maxJobActivityRows = 3
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
// (list mode and the scheduler pause label), and the jobsViewState the widgets
// read. It replaces a single constructor whose dozen closures shared seven
// mutable locals — the state each handler touches is now named on the struct
// rather than captured, and the invariants that used to be maintained by hand in
// five places live on jobsViewState.
type jobsView struct {
w fyne.Window
svc *app.Service
state *jobsViewState
dp *detailsPanel
list *widget.List
folderSelect *widget.Select
viewButton *widget.Button
stopAllButton *widget.Button
schedulerState *widget.Label
// listView and paused mirror Service-owned config so the widgets can be
// relabelled without a round trip. Both are re-read from the Service on every
// refresh; neither is a source of truth.
listView domain.JobListView
paused bool
}
const minJobsSidebarWidth float32 = 480
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// It returns the assembled panel and a refresh function the caller invokes
@@ -51,156 +25,391 @@ type jobsView struct {
// in mainwindow.go). The refresh function re-reads the service snapshot and
// redraws all widgets in the jobs view; it does NOT touch history or settings.
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
config := svc.Config()
v := &jobsView{
w: w,
svc: svc,
state: newJobsViewState(svc),
listView: config.JobListView,
paused: config.Paused,
jobs := svc.Jobs()
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
syncFromService := func() {
jobs = svc.Jobs()
for id := range runtimes {
delete(runtimes, id)
}
for _, current := range jobs {
if rt := svc.Runtime(current.ID); rt != nil {
runtimes[current.ID] = rt
}
}
}
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
v.updateDetails()
// Build order follows what refresh() touches: the folder select fires its
// OnChanged from SetSelected below, which refreshes, so every widget that
// refresh() reaches has to exist by then.
v.list = v.newList()
v.viewButton = v.newViewToggle()
globalControls := v.newGlobalControls()
v.folderSelect = v.newFolderSelect()
v.folderSelect.SetSelected(v.state.folder)
v.syncListSelection()
return v.assemble(globalControls), v.refresh
}
// refresh re-reads the Service and redraws the whole view. It is the single
// entry point for "something changed": the toolbar handlers call it after a
// successful operation, and mainwindow's event observer calls it for everything
// else.
func (v *jobsView) refresh() {
v.state.sync()
// The pause state is Service-owned and can change from outside this view, so
// it is re-read here rather than mirrored from the tap handler alone — that is
// what makes this view a consumer of SchedulerStateChanged.
v.applySchedulerState(v.svc.Config().Paused)
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
// paths do), so refreshing the activity list again here would redraw it twice
// per call.
v.updateDetails()
v.list.Refresh()
v.syncListSelection()
}
// updateDetails repopulates the details pane from the current selection.
func (v *jobsView) updateDetails() {
current, ok := v.state.selected()
if !ok {
// A folder filter can temporarily leave no selectable rows. Clearing the
// details panel avoids showing stale information for a hidden job.
v.dp.clear()
return
syncFromService()
runtimeFor := func(index int) *domain.JobRuntime {
if index < 0 || index >= len(jobs) {
return &domain.JobRuntime{}
}
if rt := runtimes[jobs[index].ID]; rt != nil {
return rt
}
return &domain.JobRuntime{}
}
// Overlap policy and the default timeout are global settings that can change
// from the Settings tab while this view is open, so they are re-read on every
// update rather than captured once at construction.
config := v.svc.Config()
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
}
// syncListSelection points the list's highlight at the selected job. It is what
// keeps the highlight and the details pane describing the same job when the row
// a job sits in moves — a job created or deleted above it, a folder filter
// applied, or a different jobs file adopted. widget.List.Select returns early
// when the row is already highlighted, so calling this on every refresh does not
// fight the user's scrolling.
func (v *jobsView) syncListSelection() {
row := v.state.displayRow()
if row < 0 {
v.list.UnselectAll()
return
selected := 0
selectedFolder := allFolders
schedulerPaused := false
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
title.Wrapping = fyne.TextWrapBreak
folderLabel := newJobDetailLabel(jobs[selected].Folder)
scheduleLabel := newJobDetailLabel(jobs[selected].Schedule)
commandLabel := newJobDetailLabel(jobs[selected].Command)
argumentsLabel := newJobDetailLabel(jobs[selected].Arguments)
successExitCodesLabel := newJobDetailLabel(app.DisplaySuccessExitCodes(jobs[selected].SuccessExitCodes))
runModeLabel := newJobDetailLabel(app.DisplayRunMode(jobs[selected]))
selectedRuntime := runtimeFor(selected)
lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun)
nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun)
stateLabel := newJobDetailLabel(selectedRuntime.LastState)
schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewTextGrid()
commandOutput.SetText(selectedRuntime.Output)
commandOutputScroll := container.NewScroll(commandOutput)
// Command output can contain long lines and preserved whitespace. TextGrid is
// used instead of Label so stdout/stderr remains readable and does not vanish
// against the theme when it is placed inside a scroll container.
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
selectedLogs := append([]event(nil), selectedRuntime.Logs...)
jobLogs := widget.NewList(
func() int { return len(selectedLogs) },
func() fyne.CanvasObject { return widget.NewLabel("log") },
func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(app.EventText(selectedLogs[id]))
},
)
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
// A folder filter can temporarily leave no selectable rows. Clearing
// the details panel avoids showing stale information for a hidden job.
title.SetText("No job selected")
folderLabel.SetText("")
scheduleLabel.SetText("")
commandLabel.SetText("")
argumentsLabel.SetText("")
successExitCodesLabel.SetText("")
runModeLabel.SetText("")
lastRunLabel.SetText("")
nextRunLabel.SetText("")
stateLabel.SetText("")
commandOutput.SetText("")
selectedLogs = nil
return
}
selected = index
current := jobs[selected]
rt := runtimeFor(selected)
title.SetText(current.Name)
folderLabel.SetText(app.DisplayFolder(current.Folder))
scheduleLabel.SetText(current.Schedule)
commandLabel.SetText(current.Command)
argumentsLabel.SetText(app.DisplayArguments(current.Arguments))
successExitCodesLabel.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
runModeLabel.SetText(app.DisplayRunMode(current))
lastRunLabel.SetText(rt.LastRun)
nextRunLabel.SetText(rt.NextRun)
stateLabel.SetText(rt.LastState)
commandOutput.SetText(rt.Output)
selectedLogs = append(selectedLogs[:0], rt.Logs...)
}
v.list.Select(row)
}
// rebuildFolders re-derives the folder filter's options from the current jobs.
// Creating, editing, and deleting a job can all add or remove a folder.
func (v *jobsView) rebuildFolders() {
v.folderSelect.Options = folderOptions(v.state.jobs)
v.folderSelect.Refresh()
}
// list and folderSelect are declared early so closures below can reference
// them before the widget.NewList / widget.NewSelect calls assign the values.
var list *widget.List
var folderSelect *widget.Select
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
// the details pane into the master/detail split the tab shows.
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
// The whole filter is one row: caption on the left, view toggle on the right,
// select filling what is left. The border layout gives both edges their
// MinSize, so the header is a line shorter than a stacked caption would make it.
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
refreshView := func() {
syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
jobLogs.Refresh()
if list != nil {
list.Refresh()
}
}
// A split rather than a Border left slot: the border pinned the sidebar at its
// MinSize forever, so the user could never trade list width for detail width.
// The divider lets either pane grow, and neither can be dragged below its own
// content minimum.
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
return panel
}
list = widget.NewList(
func() int { return len(filteredJobs) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
return container.NewVBox(name, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container)
name := row.Objects[0].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
// and, when the selected job is no longer visible, moves the selection to the
// first row that is (see jobsViewState.applyFilter).
func (v *jobsView) newFolderSelect() *widget.Select {
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
current := jobs[filteredJobs[id]]
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
status.SetText(app.StatusText(current, runtimes[current.ID]))
},
)
list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(filteredJobs) {
updateDetails(-1)
return
}
updateDetails(filteredJobs[id])
}
list.Select(selected)
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
return
}
v.state.applyFilter(value)
v.refresh()
})
}
// newGlobalControls builds the pause control row that sits above the filter.
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
v.schedulerState = widget.NewLabel("")
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
v.applySchedulerState(v.paused)
v.stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. refresh re-derives the
// pause state from the Service, so a failed save leaves the control showing
// what actually happened.
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
dialog.ShowError(err, v.w)
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
if len(filteredJobs) == 0 {
// The "No folder" filter is intentionally allowed to be empty. It is a
// real filter choice, not an error state, so the selection is cleared.
selected = -1
updateDetails(-1)
return
}
v.refresh()
selected = filteredJobs[0]
list.Select(0)
refreshView()
})
folderSelect.SetSelected(selectedFolder)
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
// The Service assigns the ID, stores the job, records the "Created"
// activity, and emits events. The observer appends those to History; we
// only refresh the snapshot and move the selection to the new job.
created, err := svc.CreateJob(saved)
if err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
targetFolder := filterValue(created.Folder)
if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder)
}
selected = indexOfID(jobs, created.ID)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
list.Select(app.DisplayIndex(filteredJobs, selected))
refreshView()
})
})
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
// The job keeps its ID, so the Service preserves the runtime (keyed by
// ID), reflects any enabled/disabled change, recomputes the next run, and
// emits the "Updated" activity the observer records.
saved.ID = jobs[selected].ID
if err := svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
list.Refresh()
refreshView()
})
})
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
if schedulerPaused {
// The global pause is treated as an emergency stop for all execution,
// including manual "Run now", so the user has one reliable switch.
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return
}
// RunNow refuses an already-running job (it returns an error); the UI has
// always ignored that case silently, so the run simply does not start.
if err := svc.RunNow(jobs[selected].ID); err != nil {
return
}
list.Refresh()
refreshView()
})
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
stopAllButton.OnTapped = func() {
// SetGlobalPause flips the Service's pause flag, updates every job's
// next-run text, and emits the activity record the observer logs. Mirror the
// new state into the local flag and the controls; revert it if the save fails.
schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused
dialog.ShowError(err, w)
return
}
if schedulerPaused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Resume all")
stopAllButton.SetIcon(theme.MediaPlayIcon())
} else {
schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Pause all")
stopAllButton.SetIcon(theme.MediaStopIcon())
}
list.Refresh()
refreshView()
}
// The row sits directly under the tab bar with no AppTabs inset, while the
// default VBox gap below it is one theme padding — add the same on top so
// the button is not flush against the tabs.
return container.New(
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
// SetEnabled toggles the job, updates its runtime/next-run, and records the
// "Resumed"/"Paused" activity the observer logs.
current := jobs[selected]
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
list.Refresh()
refreshView()
})
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
deleted := jobs[selected]
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
// The Service removes the job and its runtime, persists, and records the
// "Deleted" activity the observer logs; the UI re-reads the snapshot and
// fixes up the folder filter and selection.
if err := svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 && selectedFolder != allFolders {
selectedFolder = allFolders
folderSelect.SetSelected(allFolders)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
}
if len(filteredJobs) == 0 {
selected = -1
} else {
selected = filteredJobs[0]
}
list.Refresh()
if selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
refreshView()
}, w)
})
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar)
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
details := container.NewVBox(
title,
widget.NewSeparator(),
detailRow("Folder", folderLabel),
detailRow("Schedule", scheduleLabel),
detailRow("Command", commandLabel),
detailRow("Arguments", argumentsLabel),
detailRow("Success exit codes", successExitCodesLabel),
detailRow("Run mode", runModeLabel),
detailRow("Last run", lastRunLabel),
detailRow("Next run", nextRunLabel),
detailRow("State", stateLabel),
widget.NewSeparator(),
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
commandOutputScroll,
widget.NewSeparator(),
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
jobLogs,
)
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
return panel, refreshView
}
// applySchedulerState is the one place that draws the pause control and its
// status text from a pause value, so refresh can drive it from whatever the
// Service reports instead of only the tap handler mirroring its own toggle.
func (v *jobsView) applySchedulerState(paused bool) {
v.paused = paused
if paused {
v.schedulerState.SetText("Scheduler paused")
v.stopAllButton.SetText("Enable auto")
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
return
func filteredJobIndexes(jobs []job, folder string) []int {
indexes := make([]int, 0, len(jobs))
for index, current := range jobs {
if folder == allFolders || filterValue(current.Folder) == folder {
indexes = append(indexes, index)
}
}
v.schedulerState.SetText("Scheduler running")
v.stopAllButton.SetText("Disable auto")
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
return indexes
}
func folderOptions(jobs []job) []string {
// "All" and "No folder" are always present so the filter UI is stable even
// before the user creates folders.
options := []string{allFolders, noFolder}
seen := map[string]bool{allFolders: true, noFolder: true}
for _, current := range jobs {
folder := strings.TrimSpace(current.Folder)
if folder == "" || seen[folder] {
continue
}
seen[folder] = true
options = append(options, folder)
}
return options
}
func filterValue(folder string) string {
if strings.TrimSpace(folder) == "" {
return noFolder
}
return strings.TrimSpace(folder)
}
func indexOfID(jobs []job, id int) int {
for index, current := range jobs {
if current.ID == id {
return index
}
}
return 0
}
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate
return container.NewGridWithColumns(2, caption, value)
}
func newJobDetailLabel(text string) *widget.Label {
label := widget.NewLabel(text)
// Job names, commands, and paths can be much wider than the details panel.
// Breaking long runs of text keeps Label.MinSize stable when the selection
// changes, so the right panel does not force the whole window to resize.
label.Wrapping = fyne.TextWrapBreak
return label
}

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